Compare commits

...
546 Commits
Author SHA1 Message Date
semantic-release-bot 617cd5b940 chore(release): 2.20.0 [skip ci] 2026-10-07 23:38:55 +00:00
Dumitru Picalau f6d2d9c4c0 Merge pull request #62 from DumiFlex/dev
Release: merge dev into main
2026-10-08 01:38:10 +02:00
Dumitru Picalau f11293a9f3 Merge pull request #59 from DumiFlex/claude/project-thread-uc7ure
fix(image-filter): Details values no longer squash; loop edit frame resets
2026-10-07 23:33:23 +02:00
Claude e4657fd7b2 fix(image-filter): details values no longer squash; whole-word value marks; loop edit frame resets
The Details value list used grid rows whose overflow-hidden cells could
shrink to nothing in a capped box (seen in the wild); it now uses plain
flex rows, and the zoom aside never shrinks its sections. Values only
mark whole words in the prompt. The Context Loop's edit-frame cursor
goes back to base when the count drops below it or the sweep changes.
2026-10-07 21:26:35 +00:00
Dumitru Picalau 7497e640b3 Merge pull request #58 from DumiFlex/claude/project-thread-uc7ure
feat(image-filter): frame-click zoom, Details, Fit; loop locks follow frame changes
2026-10-07 23:05:16 +02:00
Claude 89242d9198 fix(image-filter): details value list scrolls and cuts long names and values 2026-10-07 20:59:15 +00:00
Claude 87502a0619 fix(image-filter): fit leaves slack so rows never wrap, and centres safely 2026-10-07 20:54:20 +00:00
Claude f8c156cd82 feat(image-filter): fit button scales the overview to fill the picker
Fit (or F) sizes the tiles so every image fits the picker without
scrolling, using the images' shape; remembered across runs.
2026-10-07 20:49:13 +00:00
Claude 8878ca879c feat(image-filter): details panel in the zoom; loop locks follow frame changes
I (or Details) adds the frame's variable values, seed, other images and
the prompt with each value marked to the zoom. The node now sends each
frame's variables. Lowering a Context Loop's count drops seed locks and
bypasses past it, and a sweep edit moves them with their combination
instead of leaving them on whatever frame lands there. Budget 468 KB.
2026-10-07 20:43:29 +00:00
Claude ac27f96667 feat(image-filter): click a frame or the header button to zoom and refine
Clicking an image only picks it; clicking the frame around it (border or
label) opens the zoom, and a Zoom & refine header button toggles it, so
prompt edits and mask painting are findable without knowing Space. A
frame with several images gets an 'all' button that picks the frame.
2026-10-07 19:03:37 +00:00
Dumitru Picalau b5dde4b295 Merge pull request #57 from DumiFlex/claude/project-thread-uc7ure
feat(nodes): pick which images go on with WP Image Filter
2026-10-07 20:32:42 +02:00
Claude c45d3d56fa feat(image-filter): sweep grid with rows, columns and split-by for any number of wildcards 2026-10-07 18:21:25 +00:00
Claude f7f3a04844 docs(image-filter): refine step, labels, sweep grid, send to loop and the carry-over picture 2026-10-07 17:55:51 +00:00
Claude ebc1ea9978 feat(image-filter): refine step with prompt edits, masks, sweep grid and send to loop
The picker now labels frames with their swept options, seed and prompts,
lays a sweep of two or more wildcards out as a grid, compares two images
in zoom, remembers whether it was left zoomed, and can send the picks back
to the Context Loop (locking kept frames' seeds, bypassing the rest).

In zoom a Refine panel edits the frame's positive/negative prompt and
paints a mask. Edits ride along with the answer: text outputs follow the
edit, an optional clip input re-encodes the conditioning, and painted
masks go to the masks output (alpha of a PNG, resized to the image).

The tab title shows a mark while images wait, and an opt-in setting sends
a desktop notification when the tab is in the background. The "Nothing
picked" option is gone: the picker can't send an empty pick, and the
timeout rule covers the only case left.

Raises the bundle budget 452 -> 464 KB for the larger lazy picker chunk.
2026-10-07 17:49:42 +00:00
Claude 4468fc4e64 fix(context-loop): lock the count widget while a sweep sets it 2026-10-07 17:24:22 +00:00
Claude df97210d74 feat(context-loop): none button to clear a swept wildcard's options 2026-10-07 17:12:48 +00:00
Claude ab1f8e78ed feat(image-filter): chime when the picker opens, with a setting to turn it off 2026-10-07 16:34:54 +00:00
Claude 1a76ea9d45 feat(nodes): pick which images go on with WP Image Filter
A new node that pauses the run and lets you pick images in a picker.
Only the picks continue, and every other slot follows them: latent and
masks are sliced, batched CONDITIONING is sliced (pooled_output too),
prompt texts and Contexts are kept or dropped with their frame, and two
MatchType extras carry anything else. Works per frame for Context Loop
lists, sends picks as the same shape or one item per image, can reuse
the last picks or pass everything, and stops only its own branch when
asked. The picker can be minimised without losing picks.

Raises the js/ bundle budget from 440 to 452 KB for the picker glue.
2026-10-07 16:20:18 +00:00
semantic-release-bot 9e64217eeb chore(release): 2.19.0 [skip ci] 2026-10-07 00:03:16 +00:00
Dumitru Picalau 183734df43 Merge pull request #56 from DumiFlex/dev
Release: merge dev into main
2026-10-07 02:02:16 +02:00
Dumitru Picalau 6ab131a965 Merge pull request #55 from DumiFlex/claude/project-thread-zae8g9
fix(manager): drive Update Now through either ComfyUI Manager and confirm the restart
2026-10-07 01:51:32 +02:00
Claude 1ac50fc67f fix(manager): drive Update Now through either ComfyUI Manager and confirm the restart
The updater only spoke the legacy ComfyUI-Manager custom node's /manager/*
API. The Manager built into ComfyUI serves /v2/manager/* and queues installs
as tasks (or through /v2/manager/queue/batch in its legacy UI), so Update
Now fell back to manual steps and the Settings restart button was missing.

Detect which Manager answers and use its routes. After the restart, wait
for a new startup id, read X-WP-Version and reload the page when the target
version is running; otherwise report that the update didn't apply instead
of leaving a stale page that looks updated.
2026-10-06 23:39:06 +00:00
Dumitru Picalau 261acc7635 feat(conflicts): warn when a constraint can rule out every target option (#54)
The canvas now flags a constraint row with "rules out all" when some
source pick leaves its target wildcard with nothing to pick and no
fallback to stand in.

Also fixes the All items list, which stopped at 1000 rows: it now loads
the whole library and pages client-side.
2026-10-07 01:21:51 +02:00
Dumitru Picalau e55fd46720 fix: sweep Limit arrows and bypass dimming for text boxes (#53)
Give the sweep Limit field the custom stepper arrows the seed rows use, and dim the Assembler and Prompt Cleaner text boxes when the node is bypassed or muted.
2026-10-06 21:50:29 +02:00
Dumitru Picalau 4be2882c4c feat(import): import Dynamic Prompts, PPP and Impact wildcard packs (#52)
Adds the Wildcard packs tab: convert a folder, files or zip, review the plan, merge folders of related lists into tagged wildcards, then import.
2026-10-06 21:42:23 +02:00
Claude ac4d7d09e6 feat(import): merge folders of related wildcard lists into one tagged wildcard
A folder of small lists (hair/colors/red, blonde, dark) now imports as
one wildcard whose options carry the list's path as tags, with one tag
group per folder level, like a hand-built wildcard. Same-named lists in
sibling folders share a tag, so female/footwear + male/footwear become a
gender axis and a garment axis. References to a merged list become a
tag filter on the merged wildcard, and globs over it collapse into one.

Entry points and lists that would make the merged wildcard reference
itself stay separate. The review tab marks merged rows, lists each
list with its tags, and can keep a folder separate; a setting turns
merging off. Billions of Wildcards: 2,653 lists -> 935 wildcards.
2026-10-06 19:13:13 +00:00
Claude e1936d5bed feat(import): review wildcard packs on their own tab before importing
Wildcard pack import moves out of the Import tab into its own tab on
Import / Export, in three steps: load the pack, review the plan, then
pick and import. The review shows every wildcard with its role and how
faithfully it carries over, filterable by role, fidelity, domain and
name, beside the import settings, the files read and the conversion
notes. The picker then starts with everything selected.

A pack dropped on the Import tab opens in the new tab, and ?tab=packs
links to it. Docs, README and release notes describe the new flow.
2026-10-06 16:24:29 +00:00
Claude 102e4aaaf2 fix(import): keep wildcard pack odds and picks exact, bundle entry points only
- ${name} stays as text with a note: a wildcard can't read a variable
- __N$$x__ and N#__x__ become {N$$sep$$@{x}}, which draws N different
  values like the source instead of allowing repeats
- a line written twice becomes one option with the summed weight
- # comment lines (txt files and multi-line YAML values) go to the module
  description; multi-line YAML values are joined into one prompt
- the report carries a plan: each row's role (entry point, composition,
  vocabulary, group) from the reference graph and its fidelity (exact,
  close, lossy) from its notes
- bundles hold the entry points only; lists stay in the library
- use libyaml's loader when it is available
2026-10-06 16:21:05 +00:00
Claude 75050bfce0 build: install PyYAML for the Python test suite 2026-10-06 06:21:02 +00:00
Claude d498e19a2e feat(import): import Dynamic Prompts, PPP and Impact wildcard files
Convert .txt, .yaml/.yml and .json wildcard files (or a .zip of them)
into one wildcard module per list, named by path, with references turned
into @{} links, glob and parent-key references into group wildcards,
PPP labels, weights and else choices into sub-categories, weights and the
fallback. Optionally files them into one bundle per top folder plus a
pack bundle. A report lists everything that converts differently.

Also fixes import commits that add a category alongside its modules
(categories now go in first, and members follow a name merge), and lets
the Import / Export view load more than 1000 library rows.
2026-10-06 06:17:36 +00:00
Dumitru Picalau 6e53ead8ca Merge pull request #51: Model-aware pipelines
feat: WP Model Info node, Match variable wildcards and LoRA by model preset
2026-10-06 07:51:17 +02:00
Claude 56047962dd fix(model-info): use the shared row buttons and always re-run the node
The globe, pin and remove buttons now use the row-primitive icon
buttons the Injector and module rows use. The node re-runs every time:
a cached run sends no UI payload, so the widget lost the detected
family after a repeat run.
2026-10-06 05:43:30 +00:00
Claude 68326e855a feat(model-info): keep the model variables out of the assembler unless shown
Each Detected row gets the Injector's globe. All three start internal:
later rules read them, the Assembler and its chips leave them out until
the globe is clicked. The canvas walkers and preview chain follow the
same flags.
2026-10-06 05:31:17 +00:00
Claude dd234875db feat(model-info): give the node a proper widget and drop the string outputs
Detected values with their source and a pin per row replace the
override text boxes; variant rules become editable rows that light up
on a match. The family shows after a run via the node's UI payload.
The node now outputs only its context.
2026-10-06 04:39:42 +00:00
Claude 929cd53652 feat: add a model info node and match-variable wildcards for model-aware pipelines
WP Model Info writes $model_family (from the model's config class),
$model_variant (file-name rules) and $model_name into the Context, reading
the checkpoint name by following its MODEL link back to the loader. Graph
walkers, the assembler preview and WP Debug know the new variables.

A wildcard instance can now match a variable instead of rolling, so a
model wildcard becomes a constraint source. The derivation editor gains a
LoRA by model preset. Docs, node help, README and release notes updated;
bundle budget raised to 432 KB.
2026-10-06 03:53:50 +00:00
Dumitru Picalau cdd8801df2 Merge pull request #50: Cleaner prompt syntax rules
feat(cleaner): empty groups, merge weights and LoRA spacing rules
2026-10-06 04:39:20 +02:00
Claude 291ac58217 feat(cleaner): empty groups, merge weights and LoRA spacing rules
Three prompt-syntax rules for WP Prompt Cleaner, run before whitespace so
the existing rules tidy the seams they leave:

- empty_groups drops (), [], ( , ), (:1.2) and groups left empty
- merge_weights folds nested ( ) weights into one group, ComfyUI-style
  (((a:1.1):1.2) -> (a:1.32)), and unwraps a weight of 1
- lora_spacing tidies <lora:...> tags and the space around them

Balanced turns on empty groups + LoRA spacing; aggressive adds merge
weights. The negative column follows the same presets.
2026-10-06 02:31:42 +00:00
Dumitru Picalau c495d73e12 Merge pull request #49: Context Loop sweep mode
Run every combination of picked wildcards, one frame each, up to a limit.
2026-10-06 01:23:31 +02:00
Claude 4c7303e03b docs(context-loop): describe sweep in the node help page 2026-10-05 19:26:23 +00:00
Claude f8d8ad6942 docs(context-loop): release note, README and Seeds & loops mention for sweep 2026-10-05 19:23:32 +00:00
Claude 91f411b207 feat(context-loop): open sweep settings in a menu from the node
The Sweep panel moves off the node into a modal opened from a 'Sweep
combinations' button, like Per-iteration seeds. The button shows the
frame count; the modal also previews every frame in run order.
2026-10-05 19:19:36 +00:00
Claude e00f38b6ff feat(context-loop): sweep mode runs every combination of chosen wildcards
Pick downstream wildcards in the Context Loop's new Sweep panel and the
loop runs the cartesian product of their options, one frame each, up to a
limit. Each frame pins the swept wildcards through the existing pinned
path; 'Hold other picks' keeps unswept modules on the frame-0 seed.
Raises the bundle budget 424 -> 428 KB for the panel.
2026-10-05 19:12:01 +00:00
Dumitru Picalau 2513914d0a Merge pull request #48: tag README links to the community site
Adds utm_source=github&utm_medium=readme to the README's community links.
2026-10-05 20:15:53 +02:00
Claude 2c6aaf9189 docs: tag README links to the community site for analytics 2026-10-05 18:14:22 +00:00
Dumitru Picalau 7d11458bb0 Merge pull request #47: setting to turn off Community tab usage stats
feat(community): setting to turn off anonymous Community tab usage stats
2026-10-05 19:08:45 +02:00
Claude 8c85ff84bf docs(settings): mention privacy in the General section blurb 2026-10-05 16:38:49 +00:00
Claude f3415cd95f feat(community): setting to turn off anonymous Community tab usage stats
Settings > General > Privacy gets "Share anonymous usage stats" (on by
default, stored as wp-usage-stats). Community.vue passes it to the embed as
analytics, which the community embed uses to skip its usage pings.
2026-10-05 16:33:58 +00:00
Dumitru Picalau 08da15fc26 Merge pull request #46 from DumiFlex/claude/wildcard-fallback-iuq0qo
feat(wildcard): fallback option rolls normally and also stands in
2026-10-05 15:19:19 +02:00
Claude 2d16f9979f docs(wildcard): describe the fallback as a normal option in comments 2026-10-02 16:46:37 +00:00
Claude 417353e92f feat(wildcard): fallback option rolls normally and also stands in
The flagged option now keeps its weight and takes part in normal draws.
It is still taken, without a draw, when constraints or weights leave
nothing to pick. Flagging an option never changes a normal pick, and a
fallback weighted 0 only ever stands in. Editors give it its normal
share and show a fallback chip beside it.
2026-10-02 16:42:36 +00:00
Dumitru Picalau 0b14330812 Merge pull request #45 from DumiFlex/claude/wildcard-fallback-iuq0qo
feat(wildcard): fallback option used when nothing else is left to pick
2026-10-02 18:21:29 +02:00
Claude a11ceaac1c feat(wildcard): fallback option used when nothing else is left to pick
A wildcard option can be marked as the fallback. It never takes part in a
normal draw and is used only when constraints (an Only rule included), a
filter or zero weights leave nothing else to pick, instead of resolving to
an empty string. Works for top-level wildcards and nested @{} refs, and
emits an info-level `fallback_used` note in place of
`constraint_excludes_all_options`.

- engine: engine/modules/_fallback.py shared by the handler and
  ref_option_pool; payload validation allows one boolean fallback on a
  non-null option
- schema 9 (additive): FALLBACK_SCHEMA_VERSION stamped in TS and Python,
  MAX_KNOWN_SCHEMA_VERSION 9, strict validator keeps the flag, parity
  fixture regenerated
- library editor: "set as fallback" on an option's probability cell, a
  fallback chip, its weight disabled and excluded from the shares
- canvas: FALLBACK badge on the option row, no share in probabilities
- WP Debug: "Fallback used" label and step fact
- docs, release note, CLAUDE.md
- bundle budget raised 420 -> 424 KB (maintainer approved)
2026-10-02 16:10:31 +00:00
Dumitru Picalau 5fca15873e docs: point help to Discord, add the community site and badge icons (#44)
Remove the Discussions link, make Discord the place for questions, bugs, feature requests and sharing, link the community site, add icons to every badge and align the title logo.
2026-10-02 17:30:46 +02:00
semantic-release-bot 83da31306d chore(release): 2.18.0 [skip ci] 2026-09-30 16:46:24 +00:00
Dumitru Picalau c90a4a8d4b Merge pull request #42 from DumiFlex/dev
feat: release dev (send-to-negative, multi-action derivations, AND/OR conditions, settings, debug and Test Runner work)
2026-09-30 18:45:30 +02:00
Dumitru Picalau 48c0c13994 Merge pull request #43 from DumiFlex/claude/project-thread-n6n28e
feat(derivation): several actions per branch, $var.neg, Replace negative, roomier canvas rules
2026-09-30 18:37:43 +02:00
Claude 8d24c8822d feat(derivation): stacked branch cards on the canvas and a Replace negative action
The node modal's Rules section shows each IF / ELIF / ELSE as its own card,
with one row per test and per action and a full-width value field, so long
values wrap instead of being squeezed into a table column. Nested AND / OR
groups read as indented rows under an "ANY of" / "ALL of" header. An empty
field shows the library value (chips included) as a ghost; an overridden one
lights up with the library value underneath and a "Use library" reset.

Derivations get a fifth action mode, `negative_replace` ("Replace negative"):
the value becomes the target variable's only negative, and the variable's own
text is left alone. Schema v8, like "Add to negative".
2026-09-30 16:25:49 +00:00
Claude add6506225 feat(derivation): run several actions per branch and read negatives with $var.neg
A derivation branch (and its ELSE) now runs its action and then each
entry of an optional extra_actions list, in order; each action sees the
previous one's write. The library editor gets "+ And" rows, the canvas
modal shows every action and gives each its own value override (keyed
"bi.K" / "else.K"), and WP Debug lists every action a branch ran.

$name.neg / $name.K.neg reads the negatives a variable carries. It rides
the accessor grammar's axis slot, so "neg" is reserved as an accepts
group name. Reading .neg is not a read of the variable, so it carries no
negatives along. Works in values, combines, the Assembler's negative
template and conditions; autocomplete offers it after the dot.

Both stamp schema v8. Cascade fixers and scans now walk the real
derivation action shape (action, extra_actions, else) instead of a stale
"actions" list, and read-only previews keep a var chip's accessor.
2026-09-30 15:33:01 +00:00
Claude 9b567f38d8 fix(canvas): toggle fixed values one at a time and keep wildcard icons purple
Fixed-value rows without ids were all keyed by the same missing id, so
disabling one disabled every row. Rows are now keyed by their id, or by
their position when the id is missing or repeated, in the canvas editor and
the engine alike.

The wildcard type icon took its color from a bare selector that tied with
the shared grey default, which several lazy chunks carry. Whichever chunk
loaded last won, so wildcard icons turned grey after opening a modal or
adding some nodes. A data-kind selector now outranks it.
2026-09-30 14:50:17 +00:00
Dumitru Picalau c6011f2044 Merge pull request #41 from DumiFlex/claude/project-thread-n6n28e
feat: send-to-negative (per-variable negatives, schema v8)
2026-09-30 12:18:17 +02:00
Claude 394918bfea feat(cleaner): type a negative into the Prompt Cleaner
The Cleaner's negative input is now a text box under the prompt box, so the
node cleans a prompt and a negative on its own without any wiring. The
rules panel moves below both boxes; workflows saved with the old two
widget values get their rules back on load.
2026-09-30 10:09:01 +00:00
Claude 6d43879855 Merge branch 'dev' into claude/project-thread-n6n28e 2026-09-30 10:01:01 +00:00
Dumitru Picalau 57393d6a84 Merge pull request #38 from DumiFlex/claude/project-thread-cctnd9
docs(readme): rewrite README for users, shorter and up to date with dev
2026-09-30 11:50:18 +02:00
Dumitru Picalau 855cdae2a8 Merge pull request #40 from DumiFlex/claude/project-thread-emxwwb
feat(settings): sectioned Settings page, database backups and new preferences
2026-09-30 11:38:45 +02:00
Dumitru Picalau c9325e39cd Merge pull request #39 from DumiFlex/claude/project-thread-uqgqr6
fix: per-connector AND / OR, WP Debug show node and clipped values
2026-09-30 11:36:55 +02:00
Claude 2468f10a3d Merge branch 'claude/project-thread-n6n28e' into worktree-agent-a2492a3ec6fa48bf8 2026-09-30 09:36:06 +00:00
Claude 25451e52d5 perf(extension): keep negative preview code out of the boot chunk
Moves the Assembler's missing-variable scan into a small
assembler-vars module so the boot chunk no longer pulls the negative
preview code, and drops the theme import from the negative template
box (the helper already loads the tokens).
2026-09-30 09:36:02 +00:00
Claude a13bcb8568 feat(cleaner): negative rules column and last-run negative line
Adds a "neg" checkbox column so each rule can run on the negative too
(stored as negative_rules_override only where it differs from the
preset default), a "drop negative tags also in prompt" toggle
(drop_prompt_overlap, off by default), presets that set both columns,
a last-run line for the negative and a negative word count.
2026-09-30 09:36:02 +00:00
Claude 332ad5ee7e Merge branch 'worktree-agent-a65dd5ac444a22559' into claude/project-thread-n6n28e 2026-09-30 09:32:55 +00:00
Claude 967d5c57bb fix(cascade): rewrite refs and vars inside negatives
Deleting or renaming a wildcard, tag or combine output now updates the
refs in option negatives and the $vars in a combine's negative, and the
reverse-dependency index counts them.
2026-09-30 09:30:44 +00:00
Claude c2a583743b fix(debug): name trace groups by the Context node's codename
The trace header led with the node title or "Node <id>" and then the
raw graph id. A Context node now goes by its codename, the name shown on
the node itself; other nodes by their title, and the raw id is only a
fallback for a node no longer on the canvas. Title and id move to the
tooltip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkgtCKNLjk77oiA5efKPia
2026-09-30 09:29:27 +00:00
Claude 7a27262fb7 Merge branch 'worktree-agent-a8a1c9b512c870027' into claude/project-thread-n6n28e 2026-09-30 09:29:25 +00:00
Claude 0974ff5e97 feat(injector): Negative field on injector rows
The binding modal gets a Negative field under Template for socket and
template rows, with the same `$slot` / `$ref` grammar, suggestions and
insert menu. The preview shows the negative line under the value, and
the dialog notes when the row is internal (its negative reaches no
prompt). An empty negative is stored absent. Rows with a negative show a
NEG badge beside the template badge. The row type gains `negative`, and
the graph helper that hands row negatives to the Assembler preview is
tested.
2026-09-30 09:29:05 +00:00
Claude b22b325c0f feat(settings): sectioned Settings page with search, canvas settings and new preferences
The manager's Settings page was one ~3,500px column. It is now eight
sections behind a left nav with search (/settings/<section>#<setting>):
General, Appearance, Editing, Autocomplete, Canvas, Test Runner,
Library & data and Advanced.

- Density is one three-way setting (compact / comfortable / cozy) shared by
  Settings and the Tweaks panel, instead of two that could disagree.
- Accent color, sidebar, start page and reduce motion under Appearance;
  optional What's new after an update.
- Tag, LoRA and embedding autocomplete merged into one list of sources, plus
  max suggestions and minimum characters (also added to the canvas panel and
  the display playground).
- Canvas section edits the ComfyUI panel's Wildcard Pipeline settings. Writes
  go to ComfyUI's settings API and, over a BroadcastChannel, to any open
  canvas, which applies them live. Setting ids and options move to
  extension/settings-catalog.ts, checked against buildSettings by a test.
- Ref recursion limit moves to Canvas › Runs and saves to the server; the old
  localStorage value is carried over once.
- Library & data: stats strip, backups (automatic, keep N, back up now,
  restore, delete), location, and folded-away details and maintenance.
- Test Runner defaults for new scenarios (seed mode, first seed, count).
- Export / import every preference as one JSON file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMHPZ4k2kXrePkcgeYJJSF
2026-09-30 09:27:31 +00:00
Claude f86b554616 feat(engine): server-side settings, runtime ref depth limit and database backups
The Settings page's ref recursion limit was stored in localStorage and read
by nothing: every run stopped at 8. Server-side preferences now live in
user/wildcard-pipeline/settings.json (engine/prefs.py, /wp/api/settings) and
PipelineEngine.run seeds __wp_max_ref_depth__ from them, so canvas runs,
previews and the Test Runner honour it.

Database backups (engine/db/backups.py): a copy before any migration that
changes the schema, a daily copy while ComfyUI runs, pruning of automatic
copies beyond the configured count (manual ones are kept), and a restore
that is staged in the DB sidecar and applied on the next start before any
connection opens, after backing up the current database.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMHPZ4k2kXrePkcgeYJJSF
2026-09-30 09:26:44 +00:00
Claude 5b24559418 feat(manager): edit negatives in the library editors and bulk tools
Send-to-negative UI for the library:

- Wildcard options get a Negative line under the value: a `+ negative`
  ghost button while empty, a NEG-tagged RichTextInput (surface
  "wildcard": text, {a|b}, @{ref} chips with remap and the broken-ref
  note) once set. The null option may carry one. The option filter also
  matches negative text.
- Fixed values get the same line on the "fixed_values" surface. Combines
  get a Negative field ($vars + {a|b}) and a read-only Carries line: the
  negatives library modules file under each $var the template reads, plus
  the phrase's own.
- Bulk add reads `value #tag *N -- negative` (first ` -- ` splits,
  modifiers may trail either side) and `name = value -- negative`, with a
  "with negative" preview chip. The selection toolbar gains a Negative
  menu: Add to each (", "), Replace, Clear; Add and Replace skip the null
  option.
- Derivation actions gain "Add to negative": the value field is styled as
  a negative with a hint that the variable is not changed. The editor's
  load normalisation keeps the mode, the inspector describes it, the canvas
  rule summary labels it, and the static preview resolver leaves the
  variable alone.
- Canvas wildcard / fixed-value instance rows show a read-only NEG hint;
  negatives stay library content, never overridden per instance.

An emptied negative is always stored as an absent key.
2026-09-30 09:26:17 +00:00
Claude 4d74851ff3 docs: document send-to-negative
A new Negatives page in the in-app docs covers where negative words come
from, how they follow the variable, what each negative field accepts, the
Assembler's negative output and $negatives slot, quiet refs that never
shift picks, the Cleaner's negative column, the bulk `--` syntax and the
schema 8 stamp. The Assembler, Cleaner, Injector, Derivation, WP Debug
and Test Runner pages (and the node help files) describe their new
inputs, outputs and fields, and the release notes gain an entry.
2026-09-30 09:24:31 +00:00
Claude 7845823dd2 feat(assembler): negative template box and negative preview
The `negative_template` input renders through the template editor as a
collapsible negative box (collapsed when empty, state on
`node.properties.wp_negative_collapsed`, content-sized so spare height
goes to the prompt editor). It stays socketed and read-only when linked,
offers the reserved `$negatives` slot, and caret inserts still go to the
prompt template only.

The helper reads `negatives` from the preview endpoint, folds in
upstream injector rows' negatives, and renders a NEG preview line built
locally by `extension/negatives.ts`, a TS mirror of engine/negatives.py
(follow usage, `$x.N` = pick N, tag-level dedupe, `$negatives`
placement). Save/Load template carry both boxes; loading a template with
no saved negative leaves the negative box as it was.

The conflict scanner, subgraph badge and pre-run check also scan the
negative template for missing `$vars`, never reporting `$negatives`.
2026-09-30 09:24:27 +00:00
Claude 610685efea feat(publish): ship @{refs} found in option negatives as dependencies
A wildcard option's negative shares the value's grammar, so an `@{uuid}`
inside it is a real dependency: the engine resolves it (quietly) and a
download without it breaks. `listReferencedUuids` (publish / bundle deps),
`buildDepGraph` (export picker) and the import broken-ref scan now read
`options[].negative` too, and the library validity check reports a missing
ref inside a negative (the null option's included). "Add to negative"
derivation actions were already covered by the action-value scan.
2026-09-30 09:23:37 +00:00
Claude 195f65d6dd fix(debug): find nodes whose ids are text
WP Debug resolved a step's node with getNodeById(Number(id)), so a
workflow with text node ids ("wpctx") got NaN and "show node" silently
did nothing; trace headers fell back to "Node wpctx" for the same
reason. Look up the id as given (litegraph keys its node map by the raw
id) and fall back to the number.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkgtCKNLjk77oiA5efKPia
2026-09-30 09:23:23 +00:00
Claude c46ce8dba7 feat(templates): save the Assembler's negative template
Migration 019 adds an optional `negative_template` column to `templates`
(row-level, no schema bump). NULL marks a template saved before
negatives existed. The repository, importer (add, replace with
COALESCE, undo), templates API and TS types carry it, and the manager's
template editor gets a Negative template field.
2026-09-30 09:22:47 +00:00
Claude f7f73252a1 feat(test-runner): show and compare negatives per sample
Each scenario sample now carries every variable's negatives, trace writes
pass their negative through, and tracked variables record their joined
negative per seed. The Outputs tab adds a NEG line under each output,
tinted by the variable each word came from; the Samples table gains a
Negative column when any seed has one; the trace drawer shows a write's
negative. Baselines keep the output's negative per seed, and Compare diffs
it only when both sides recorded one, saying "negative not recorded" for a
baseline saved before negatives until it is saved again.
2026-09-30 09:20:15 +00:00
Claude adb20455e9 feat(debug): show each variable's negatives in WP Debug
The Variables tab lists a variable's negative words under its value in the
danger colour, naming the module they came from when it isn't the
variable's own (a derivation's Add to negative, an inherited $var, an
Injector row), with a count badge in the tab bar. The Trace tab shows an
Add to negative action as `negative: <value>` instead of an assignment,
and each write's negative under the step. The Variables copy keeps the
negatives table; Raw and the download already carry it.
2026-09-30 04:36:14 +00:00
Claude c9c20d2f36 feat(engine): show each write's negative in the trace 2026-09-30 04:28:15 +00:00
Claude 5b2bb571bf docs(claude): note schema v8 and send-to-negative conventions 2026-09-30 04:27:19 +00:00
Claude bc003617ac fix(debug): show a clipped trace value in full when the step opens
An opened trace step repeated its single value in full only when the
value was longer than 60 characters, so a shorter value clipped by a
narrow Debug node stayed clipped. The trace now measures each open row
and repeats the value whenever the row had to cut it off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkgtCKNLjk77oiA5efKPia
2026-09-30 04:22:57 +00:00
Claude 4cd180ce86 feat(schema): stamp and validate schema v8 negatives
MAX_KNOWN_SCHEMA_VERSION goes to 8 with a content stamp for any non-empty
`negative` or an "Add to negative" derivation action, mirrored in the
Python exporter. The strict validators keep the new fields, the parity
fixtures carry one of each, and the payload types learn the field and the
`negative` derivation mode.
2026-09-30 04:22:42 +00:00
Claude 7b02fcfd58 feat(engine): per-variable negatives for send-to-negative
Wildcard options, fixed values and combines can carry a `negative`, and a
derivation action gains the `negative` ("Add to negative") mode. The
engine files negatives per variable in `__wp_negatives__`, carries them
across nodes, and the Assembler gets a `negative` output built from the
variables its template rendered, filled into a `negative_template` at
`$negatives`. Negatives resolve on their own seed stream and refs inside
them resolve quietly, so picks never shift. Injector rows can set a
negative, the Prompt Cleaner cleans an optional negative with its own
rule column, and the preview endpoint returns the negatives table.
Schema v8 stamps payloads that use a negative.
2026-09-30 00:50:47 +00:00
Claude 6b15abe32e fix(debug): show node opens the subgraph the node lives in
WP Debug's "show node" returned early whenever the node that ran a step
was in a different graph than the one on screen, so it did nothing for a
Context node inside a subgraph, or for a root node while the canvas was
inside one. It now opens the node's graph with setGraph (the breadcrumb
follows), waits for ComfyUI's viewport restore, then selects and centres
the node, uncollapsing it first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkgtCKNLjk77oiA5efKPia
2026-09-30 00:49:20 +00:00
Claude 55ce0559cc fix(derivation): flip one AND / OR connector at a time
Clicking any connector in a condition group flipped every connector in
that group, so a user could not pick AND or OR per position. Each
connector now flips on its own: the members regroup the way the mixed
row reads, AND binding tighter than OR, so "a AND b AND c" with its
second connector flipped becomes "(a AND b) OR c", drawn with the AND
pair boxed. Flipping it back merges the box away. Test order, and so
every canvas value override key, is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkgtCKNLjk77oiA5efKPia
2026-09-30 00:21:31 +00:00
Claude 52c7a3af63 docs(readme): rewrite README for users, shorter and up to date with dev
Write the README for someone installing the extension rather than
someone reading the engine. Adds the release-zip install path, covers
Test Runner baselines, AND/OR derivations, placeholders, the new WP
Debug tabs and library tags, and fixes the network section, which
claimed nothing is fetched at startup while the update check runs on
launch by default and the Community page loads from wp.dumiflex.dev.
The tag-download hardening notes move to docs/network-access.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TjXpBAkr8VdNukVP1bmwye
2026-09-29 23:52:26 +00:00
Dumitru Picalau 96f2f38e8d Merge pull request #35 from DumiFlex/claude/project-thread-kc4iru
perf(graph), fix(syntax): faster long chains, no gap after empty refs, Debug release notes
2026-09-30 01:32:46 +02:00
Dumitru Picalau 0230f7f4f2 Merge pull request #37 from DumiFlex/claude/project-thread-nqqql9
docs: bring CLAUDE.md bundle budget and schema numbers up to date
2026-09-30 01:32:03 +02:00
Claude 2f0f284af1 docs: bring CLAUDE.md bundle budget and schema numbers up to date
The size gate's total budget is 420 KB, not 250 KB, and dev's
MAX_KNOWN_SCHEMA_VERSION is 7 (derivation AND/OR conditions).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LX6LJ4Nzmnkrm6caHu1kJY
2026-09-29 23:25:57 +00:00
Claude 380c99d657 Merge remote-tracking branch 'origin/dev' into claude/project-thread-kc4iru 2026-09-29 23:25:09 +00:00
Dumitru Picalau ebcae98558 Merge pull request #36 from DumiFlex/claude/project-thread-ccuz80
feat(manager): tags as collections — Tags page, rename/merge/delete, any/all match
2026-09-30 01:25:00 +02:00
Claude db94001a90 refactor(manager): sidebar Tags is a single entry, not a tag list
A library can hold hundreds of tags, so listing them in the sidebar
doesn't scale. The Tags row now opens the Tags page and its badge
shows how many distinct tags exist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxDBWA8ayXs4T8rS5w6jrC
2026-09-29 19:11:18 +00:00
Claude 474762c384 feat(manager): tags as collections — sidebar list, Tags page, any/all match
Library tags already let a module, bundle or template carry several
labels. Give them a home:

- Sidebar "Tags" under Library lists the 8 most-used tags with counts;
  each opens All items (or Templates, for template-only tags) filtered
  to it.
- New /tags page lists every tag with per-kind counts and renames,
  merges or deletes a tag across the library in one step
  (POST /wp/api/library-tags/rename|delete, through each repository's
  update so fingerprints and versions move as for an editor save).
- List filters with two or more tags get an Any tag / All tags switch
  (URL key `tagmode`).
- Glossary entry separating library tags from option sub-categories.

No payload or schema change: tags are a row column.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxDBWA8ayXs4T8rS5w6jrC
2026-09-29 19:02:43 +00:00
Claude cd99ef360c fix(syntax): close the gap an empty @{} ref leaves behind
A ref that resolves to nothing (placeholder or deleted module, a filter
with no match, an empty option) left a double space or a doubled comma
in wildcard values and editor previews. Tidy the seam in both the
engine resolver and its TS mirror; text away from the seam is kept as
written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BEU7uBVCJn1isN2RowbhhT
2026-09-29 18:03:16 +00:00
Claude a512e071e9 perf(graph): reuse the resolved upstream chain until it changes
Every Context, assembler and injector widget re-resolved its whole
upstream chain on each poll, so a series chain cost O(N^2) per round
even when nothing changed. Memoise resolveChainStatic per chain head,
keyed on the chain's nodes, their type/mode/widget strings and the
preview-resolver cacheVersion. 16-node chain: 27.7 ms -> 0.16 ms per
round of refreshes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BEU7uBVCJn1isN2RowbhhT
2026-09-29 17:53:06 +00:00
Claude f44095c123 docs(release-notes): add WP Debug overhaul entry
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BEU7uBVCJn1isN2RowbhhT
2026-09-29 17:49:16 +00:00
Dumitru Picalau de15a72a2f Merge pull request #34 from DumiFlex/claude/project-thread-ydktjp
feat(test-runner): per-kind inspector, docs page, light-theme drawer fix
2026-09-29 18:49:07 +02:00
Claude 7545dbe3e5 Merge branch 'dev' into claude/project-thread-ydktjp
Keeps both release-note lists, and teaches the inspector to spell out
the AND / OR condition groups dev added to derivations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwq95EvrKu6WgE9D5rahKy
2026-09-29 16:40:22 +00:00
Dumitru Picalau f4bbe104a6 Merge pull request #33 from DumiFlex/claude/debug-node-overhaul-3emwzw
feat(debug): overhaul WP Debug with per-module run detail
2026-09-29 18:12:46 +02:00
Dumitru Picalau 05e285783e Merge pull request #32 from DumiFlex/claude/project-thread-uqgqr6
feat: derivation AND / OR conditions, placeholder refs and broken-ref row markers
2026-09-29 18:12:38 +02:00
Claude 7d1b16f883 Merge branch 'claude/project-thread-uqgqr6' into claude/debug-node-overhaul-3emwzw
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va4FFAixLga6CVhYL1gcjy
2026-09-29 16:01:53 +00:00
Claude 2222cde45e feat(test-runner): per-kind inspector, docs page, light-theme drawer fix
Clicking a stack card opens an inspector: wildcard weight vs picked share
(never-picked options flagged), constraint link/reach/hits, combine
template and outputs, derivation rules, fixed values, bundle children.
Adds a Test Runner docs page and stops the closed drawers' shadow from
bleeding onto the page in the light theme.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwq95EvrKu6WgE9D5rahKy
2026-09-29 15:58:16 +00:00
Claude b4e35c3cb0 Merge branch 'dev' into claude/project-thread-uqgqr6
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va4FFAixLga6CVhYL1gcjy
2026-09-29 15:58:03 +00:00
Dumitru Picalau b0aa2e1628 Merge pull request #31 from DumiFlex/claude/project-thread-ydktjp
feat(test-runner): compare runs with a saved baseline
2026-09-29 17:57:30 +02:00
Claude 455e8883f2 feat(debug): fold derivation rules and keep long values tidy
Each derivation rule folds on its own (fired rules open, the rest
folded, plus open/fold all). Append and prepend actions show the piece
added and the joined result. Trace rows clamp to one line, long step
names ellipsize, and the Variables name column lines up across rows
with long names wrapping.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwo837fQ1tm6RacaQGYom1
2026-09-29 15:11:02 +00:00
Claude 9d54b9103a feat(debug): redesign WP Debug around variables, trace and warnings
The viewer now reads the engine's per-module run detail: which derivation
branch fired and why, each condition's actual value, a wildcard's odds,
pool, filter and the constraints that re-weighted it, nested @{uuid}
picks, constraint reach and hits, multi-pick items and tag axes.

Steps are grouped by Context node (title, codename, seed) with a jump to
the node on the canvas. Warnings get plain-language labels for every
engine type and link to the step that raised them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwo837fQ1tm6RacaQGYom1
2026-09-29 14:46:01 +00:00
Claude 4d178e2b7e feat(debug): record per-module run detail for WP Debug
Canvas Context runs now set an explain flag. With it, each trace row carries
the module's display name and a handler detail dict: which derivation branch
fired, with every test's result and the value it read; a wildcard's option,
odds, pool and the constraints that re-weighted it; a constraint's reach and
rule counts. Warnings a module raised name it as their owner, and trace rows,
warnings and nested @{} picks are tagged with the Context node's graph id.
WP Debug forwards the nested-pick log, node seeds, internal flags, rolled
axes, constraint hits and multi-pick items.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwo837fQ1tm6RacaQGYom1
2026-09-29 14:00:48 +00:00
Claude 1c81548faf fix(conflicts): scan derivation value overrides for broken refs
The Context node's broken-ref scan only read library action values, so a
ref typed into a canvas action-value override (a placeholder included)
never raised a node warning or subgraph badge. It now scans this
instance's action_value_overrides too.

Row markers in both derivation editors now look only at action values,
the one field that resolves refs, so they agree with the chips and the
node warning. Documents the "bi.k" condition override key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkgtCKNLjk77oiA5efKPia
2026-09-29 13:32:04 +00:00
Claude 871e9b5a7a feat(refs): placeholder refs from the @ list and broken-ref row markers
Typing @name where no module has that name now offers a last
"Placeholder @name" row. It inserts @{<fresh id>#name}, the red broken
chip under that name, which the existing remap flow repoints later.

Rows holding a ref that points at nothing are now marked where they
are: the field gets a red outline, a wildcard option row gets a red
rail plus a note naming the missing refs (library editor and canvas
modal), and a derivation branch and its rule get a red border and a
warning mark (library editor and canvas modal). Canvas rows count only
refs the server confirmed missing, so nothing flashes red while loading.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkgtCKNLjk77oiA5efKPia
2026-09-28 22:40:36 +00:00
Claude 4449bd9c82 feat(derivation): combine branch tests with AND / OR groups
A derivation branch condition can now be a group ({match: all|any,
conditions: [...]}) of tests and nested groups. The manager editor gets
+ Condition / + Group controls and a clickable AND/OR connector; the
canvas modal shows the whole expression and one value override per test
(override keys "bi" and "bi.k"). The engine gains is_empty and
is_not_empty ops, which the editor already offered.

Payloads using groups or the emptiness ops stamp schema 7
(MAX_KNOWN_SCHEMA_VERSION 6 -> 7, additive, no migrator).

Also fixes the derivation editor rewriting presence ops to equals when a
saved derivation was reopened.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkgtCKNLjk77oiA5efKPia
2026-09-28 22:15:39 +00:00
semantic-release-bot f73e122069 chore(release): 2.17.2 [skip ci] 2026-09-28 21:05:59 +00:00
Dumitru Picalau 7fb6c1b3a9 Merge pull request #30 from DumiFlex/claude/project-thread-05urmv
perf(extension): load the startup code as one chunk instead of 43
2026-09-28 23:05:00 +02:00
Claude 5699ba8521 feat(test-runner): compare runs with a saved baseline
A scenario can store a run as its baseline. Later runs are compared seed
by seed: the Compare tab lists every seed whose output changed (with a
word diff), plus variable values and warnings that appeared or went
away. Re-run all runs every pinned scenario and badges each with how it
compares, and the wildcard and derivation editors link to the saved
scenarios that use them.

The run API gains an opt-in track/track_limit that returns chosen
variables' values per seed, which is what a baseline stores.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwq95EvrKu6WgE9D5rahKy
2026-09-27 02:25:33 +00:00
Claude 4b1a3b483f docs(claude): drop the stale _shared chunk note
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zVr7mDVkhk2bo1yRPVVZa
2026-09-27 02:25:07 +00:00
Claude 889f9a3820 docs(claude): describe the boot chunk
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zVr7mDVkhk2bo1yRPVVZa
2026-09-27 02:24:58 +00:00
Claude ae149c8f77 perf(extension): load the startup code as one chunk instead of 43
main.ts awaited nineteen separate import() calls before registering the
extension. Each became its own chunk, and every module two of them shared
became another, so an empty canvas fetched 43 JS files (166.5 KB gzip)
before the extension could register.

The widget glue still has to be loaded before registration: ComfyUI does
not wait on a Promise from getCustomWidgets, and init runs right after
extension loading, so there is nothing to gain by deferring it. Instead:

- src/boot.ts gathers everything registration needs behind one import, so
  it ships as one chunk and the lazy chunks import from it.
- The toast stack mounts on the first toast, the Display playground on
  first open, the subgraph badge module when the graph has a subgraph node.
- The template editor's RichTextInput is lazy, like every other widget SFC.

Empty canvas: 2 JS files, 92.1 KB gzip. Total js/ 409.9 → 397.4 KB gzip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zVr7mDVkhk2bo1yRPVVZa
2026-09-27 02:23:38 +00:00
semantic-release-bot 5899fb1299 chore(release): 2.17.1 [skip ci] 2026-09-26 15:49:17 +00:00
Dumitru Picalau 9a2a5d6b2e Merge pull request #29 from DumiFlex/claude/project-thread-56kegc
fix(manager): headline the running release on What's new
2026-09-26 17:48:19 +02:00
Claude 0f9187c569 docs(release-notes): note the What's new headline fix
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129KsfAu5bvhBAnCS8AsyoN
2026-09-26 15:25:26 +00:00
Claude 078d738ce4 fix(manager): headline the running release on What's new
The page took its headline from the cached update check, which survives an
update for up to six hours. After updating to 2.17.0 it still said
"Release notes for v2.15.3" while the earlier-releases list below already
had 2.17.0. The headline now uses the newest release either source knows,
and a cached check or history older than the running build is refetched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129KsfAu5bvhBAnCS8AsyoN
2026-09-26 15:23:50 +00:00
semantic-release-bot 70866d6596 chore(release): 2.17.0 [skip ci] 2026-09-26 15:01:40 +00:00
Dumitru Picalau b08e34f5c4 Merge pull request #28 from DumiFlex/claude/project-thread-ydktjp
feat(manager): Test Runner scenario workbench on the real engine
2026-09-26 17:00:36 +02:00
Claude 4954b65638 Merge main into the Test Runner branch
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwq95EvrKu6WgE9D5rahKy
2026-09-26 14:48:37 +00:00
semantic-release-bot aee42389c4 chore(release): 2.16.0 [skip ci] 2026-09-26 14:46:46 +00:00
Dumitru Picalau 44db79a9e2 Merge pull request #27 from DumiFlex/claude/mjs-lazy-chunks
perf(extension): emit lazy chunks as .mjs so ComfyUI stops importing them all
2026-09-26 16:45:46 +02:00
Dumitru Picalau a1c7b886cf Merge pull request #26 from DumiFlex/claude/project-thread-q5btk3
feat(constraints): link picks with the new Only rule
2026-09-26 16:45:42 +02:00
Claude 0975a627f3 fix(test-runner): nested bundles, stale runs, bundle outputs, event loop
Self-review fixes:
- A bundle nested in a bundle now runs its modules, expanded inline the
  way the canvas insert does, instead of being skipped.
- A run that finishes after switching scenarios no longer shows its
  result on the new scenario; its summary still lands on its own.
- A stack of bundles picks a default output from the combine (or last
  binding) inside them.
- Scenario runs execute in a worker thread so a large run doesn't stall
  ComfyUI's event loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwq95EvrKu6WgE9D5rahKy
2026-09-26 14:39:27 +00:00
Claude 9ff8014590 build(release): rebuild after stamping the version so the zip matches it
The release job built js/ + web/ before semantic-release stamped the new
version, so the release zip carried a bundle one version behind its
pyproject.toml and the new update notice would never clear.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-26 14:38:38 +00:00
Claude 43b2f0b3cf docs(assets): don't name a release in the prune comment
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-26 14:35:08 +00:00
Claude 5b707f7df7 fix(manager): flag plain-text combines in the Test Runner
A combine whose template has no variables, references or choices
renders the same text on every run with no warning. Its stack card now
says so, and the Outputs note explains why the output never varies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwq95EvrKu6WgE9D5rahKy
2026-09-26 14:02:06 +00:00
Claude 40a466ec2c feat(manager): show nested wildcard picks and unset reads in the Test Runner
Samples always has a column for the output variable, so a one-module
stack no longer shows a table of bare seeds.

The seed trace now shows the nested @{ref} picks each module made, as a
tree, with what each ref expanded to. The engine records them only when
the caller seeds ctx["__wp_ref_log__"] (the Test Runner does; canvas runs
don't), and nested picks count toward their wildcard's pick totals.

A stack card now says when it reads a $var that no pin and no earlier
item sets, which is the usual reason a combine keeps producing the same
text, and the Outputs tab points at it when the output never changes.
The trace no longer shows "no change" under a module that wrote values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwq95EvrKu6WgE9D5rahKy
2026-09-26 03:59:12 +00:00
Claude aa283b9157 fix(manager): polish the Test Runner workbench
Stack cards are cleaner: a step number, a small on/off switch, and a
remove button that shows on hover. Cards reorder by drag, or Alt + arrow
keys, instead of a row of arrow buttons. Switching a card off and on
again no longer marks the scenario as changed.

The output flag and badge are gone. Which variable counts as the output
is now picked in the Outputs tab, where the choice shows its effect.

The seed control is a Range/Random switch with plain number fields, and
the header, pinned values strip and variable bars are tightened.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwq95EvrKu6WgE9D5rahKy
2026-09-26 03:42:27 +00:00
Claude f7419f2156 feat(manager): rebuild the Test Runner as a scenario workbench
The Test Runner page is now a workbench on the real engine: a rail of
saved scenarios, a stack builder that lines library modules and bundles
up like a Context node, pinned values, a seed range, and results in
Variables, Outputs, Samples and Warnings tabs with a per-seed trace.

Every run goes through POST /wp/api/test/run, so the numbers match what
the canvas produces for the same seeds. Send to Test Runner opens an
unsaved quick run and runs it straight away.

The old single-module sampler and its in-browser simulator are removed:
the /wp/api/test route, api.test, TestRequest/TestResponse and
utils/resolver.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwq95EvrKu6WgE9D5rahKy
2026-09-26 02:15:17 +00:00
Claude 87ac907403 feat(api): save Test Runner scenarios
Migration 018 adds a test_scenarios table, and /wp/api/test/scenarios
gives it list/get/create/update/delete. A scenario stores its stack
(library modules and bundles referenced live by id), pinned $var
values, seed spec and output variable, plus two JSON blobs the Test
Runner writes: a baseline to compare against and the latest run
summary. Listing can filter to the scenarios that use a given module
or bundle, for the "used in N scenarios" hint.

Scenarios are local only. They are not part of Import/Export or the
community, and this is a row-level table, so no schema_version bump.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwq95EvrKu6WgE9D5rahKy
2026-09-26 01:54:11 +00:00
Claude 1df2e487fe feat(api): run Test Runner scenarios through the real engine
Add POST /wp/api/test/run. A scenario is a stack of library modules and
bundles, optional pinned $var values and a seed spec (a range, N random
seeds or an explicit list). Each seed is one real PipelineEngine run, so
seed N here is seed N on a canvas Context node with the same modules.

The response carries per-variable counts of fully expanded values,
per-option pick counts, constraint hit totals, engine warnings grouped
with the seeds they fired on, and the first N samples with their trace.
Missing modules are reported and skipped so saved scenarios keep working
after a delete.

The engine side lives in engine/scenario.py (no ComfyUI or DB imports).
The client gets api.testRun and its types; the page moves to it in a
later change, together with removing the old /wp/api/test sampler.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwq95EvrKu6WgE9D5rahKy
2026-09-26 01:48:59 +00:00
Claude d6e1a9c2d6 perf(extension): emit lazy chunks as .mjs so ComfyUI stops importing them all
ComfyUI lists every **/*.js under WEB_DIRECTORY in /api/extensions and
the frontend imports each one, so every lazy chunk loaded on every page,
even with no WP node on the canvas (62 files, 1.73 MB raw). Only main.js
keeps .js now; an empty canvas loads 42 files, 576 KB raw.

The size gate counts .mjs and reports it under the old .js names so base
and head still diff. The stale-asset prune matches .mjs, and on the first
start after updating it deletes the old .js chunks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-26 01:03:38 +00:00
Claude bb78e94178 feat(ui): ask for a refresh when the pack was updated under an open page
/wp responses now carry X-WP-Version (the installed pyproject version).
The manager's stale banner names the new version, and the canvas shows a
one-time toast with a Refresh button, when it differs from the version
the page was loaded with.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-26 00:37:58 +00:00
Claude 4a666fcd29 fix(editors): show only links on the canvas and read newer exception keys
The canvas constraint editor now shows the same Linked note as the
manager, following instance mode overrides, disabled rows and extras.
The manager's exceptions table reads source_value/target_value first,
matching the engine, so rows written with the newer keys no longer load
blank.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-26 00:35:06 +00:00
Claude 020da57218 fix(editors): keep the exceptions table visible beside the only-link note
The Linked note sat inside the exceptions table's v-if/v-else chain, so
any only exception hid the whole table. Move it after the tables.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-25 22:42:26 +00:00
Claude fbd7b743a9 feat(editors): offer the only rule in the matrix and exceptions editors
The cell popover, matrix legend, SPA exceptions picker and canvas
exceptions cycle all gain Only. Neutral cells in a row that has an Only
rule show a faint x, since they are shut out at run time. The SPA
exceptions table says which source values are linked.

The canvas matrix now stores a neutral override as `allow`, the engine's
name for it; it used to store `neutral`, which failed the constraint.

Docs: add a Linking picks section, and correct Allow, which the docs said
keeps only the matching options when it has always meant no change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-25 22:20:49 +00:00
Claude dbc694f4d8 feat(schema): stamp schema_version 6 for constraints using the only rule
A payload with an `only` rule anywhere (library matrix or exceptions,
instance extra exceptions, instance mode overrides) now stamps
CONSTRAINT_ONLY_SCHEMA_VERSION (6) in both the TS publish path and the
Python exporter, and MAX_KNOWN_SCHEMA_VERSION moves to 6. An older engine
rejects the unknown mode outright, so the stamp makes that extension refuse
the pack and ask for an update. Additive: no migrator, CURRENT stays 2.

Needs community catalog v6 deployed first (server-first ordering).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-25 22:18:56 +00:00
Claude 3bce46b28d feat(constraints): add the only rule for linking picks
A constraint rule with mode `only` turns its row into an allow-list: when
the source fires, a target with no rule of its own in that row is excluded.
On an exception it links literal values (maid forces frilled apron); in a
matrix row it links tags. Before, a link needed an exclude rule for every
other target, and a target added later slipped through.

The rule itself weighs 1.0 and targets with any other rule keep it. A matrix
`allow` cell does not count as a rule because the editors show it as
Neutral. The axis-menu probe ignores value-level `only` so a tag link in
the same constraint still pins an accepts roll. Python and TS folds stay in
step through new constraint-corpus cases.

Also reads a legacy `neutral` cell mode override as `allow`: the canvas
matrix stored that value, and the engine rejected it, failing the whole
constraint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-25 22:17:30 +00:00
semantic-release-bot c7314e98f2 chore(release): 2.15.3 [skip ci] 2026-09-25 21:45:52 +00:00
Dumitru Picalau 6cdf7996c8 Merge pull request #25 from DumiFlex/claude/project-thread-h85wt4
fix(axes): stop silent accepts demotion on rename, and explain empty axis reads
2026-09-25 23:45:14 +02:00
Claude aef082964d fix(axes): say when a $var.AXIS read is empty because the pick has no tag
When the picked option carried no tag on a declared accepts axis, the run
warned "$outfit has no 'SHOES' axis — is that tag group marked
'accepts'?", sending users to fix a group that was fine. Wildcards now
record which accepts axes they declare (`__wp_axis_decl__`, carried across
nodes and held seeds like `__wp_axes__`), so the resolver can tell the
cases apart: an untagged pick becomes an info note, and a genuinely
unknown axis names the axes that do exist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxeNnfTMTyaKQwCYWA6frg
2026-09-25 21:25:26 +00:00
Claude 67ee027592 fix(editor): refuse renaming an accepts group to a non-identifier name
Renaming an accepts tag group to a name `$var.NAME` cannot parse used to
demote it to classify without a word, silently emptying every
`$var.AXIS` read of it. The rename is now refused with a toast (same rule
as promoting such a name), and the input puts the old name back. A name
colliding with another group now does the same instead of leaving the
input showing a name that was never stored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxeNnfTMTyaKQwCYWA6frg
2026-09-25 21:24:04 +00:00
Dumitru PicalauandClaude c436bd931f docs(chips): document chips, @{…} references and the filter grammar (#23)
New Docs page "Chips & references" (How it connects): what a chip is, its
three states (resolved, filtered, broken), how to make a placeholder $var
chip, the four-segment @{uuid#name:filter!null} form versus plain @name, and
the sub-category filter grammar (not / and / or / comma, parentheses,
precedence, reserved words, null as a flag). Registered with keywords so a
docs search for "chip" or "filter" finds it. Glossary gains Chip, Reference
and Filter expression; the wildcard and variable-pipeline pages link to it.


Claude-Session: https://claude.ai/code/session_012LhbKjx2xyGYREkvcShihy

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-25 22:59:57 +02:00
semantic-release-bot 417e36da5d chore(release): 2.15.2 [skip ci] 2026-09-25 20:46:07 +00:00
Dumitru Picalau 0d7e5b73a4 Merge pull request #24 from DumiFlex/claude/project-thread-pqody6
fix(axes): positional multi-pick axis reads + real rolls in the assembler preview
2026-09-25 22:45:29 +02:00
Claude 35b401c371 Merge remote-tracking branch 'origin/main' into claude/project-thread-pqody6 2026-09-25 20:37:56 +00:00
semantic-release-bot b8679a621e chore(release): 2.15.1 [skip ci] 2026-09-25 20:35:58 +00:00
Dumitru Picalau 918ba26b27 Merge pull request #22 from DumiFlex/claude/project-thread-w2diej
fix(export): stamp library file exports with the schema version their content needs
2026-09-25 22:35:14 +02:00
Claude 9f0f38a0a6 fix(assembler): preview $var.AXIS from the preview run's roll
The assembler preview filled every `$var.AXIS` with the axis's first
tag, independent of the option the preview had picked, so it could show
"sandals | robe | red stilettos". /wp/api/preview/resolve now returns
the rolled `__wp_axes__` beside the values and the preview reads it
with the same shaping as the engine, falling back to the first-tag
guess only until the run answers. Also accepts the axis-first
`$var.AXIS.K` order the engine already parses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011fQ7Lkc8AxwXqsRZrFBxE9
2026-09-25 20:18:38 +00:00
Claude 4afbb5a08e fix(axes): keep multi-pick axis reads positional
`$outfit.K.AXIS` compacted away picks whose option has no tag on the
axis, so every later index slid down and `$outfit.0.SHOES` could print
pick 1's shoe beside pick 0. Both the resolver (`get_axis`) and the
derivation condition reader now keep one slot per pick.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011fQ7Lkc8AxwXqsRZrFBxE9
2026-09-25 20:18:14 +00:00
Claude 6d8b609454 fix(export): stamp library file exports with the schema version their content needs
build_export_payload always stamped CURRENT_SCHEMA_VERSION (2), so a file
export using range multi-picks, a non-default constraint target_select or
an accepts tag axis went out as v2 and an older extension installed it
while misreading or dropping those features.

Port the TS schemaVersionForPayload logic to engine/migrations/stamping.py
(same SP2b regex, reach and tag-axis walks) and stamp the export from all
buckets, auto-included inner bundles included. The SP2B / SP3 / TAG_AXES
constants now also live in engine/migrations beside MAX_KNOWN.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174Xsr85WVPSVjSREXkSi5a
2026-09-25 19:55:46 +00:00
semantic-release-bot 7960d46085 chore(release): 2.15.0 [skip ci] 2026-09-25 12:15:41 +00:00
Dumitru Picalau ad4b78cfbb Merge pull request #21 from DumiFlex/claude/project-thread-slpl3o
Unify schema_version stamps: v5 for accepts tag axes, installs keep the pack's own version
2026-09-25 14:14:27 +02:00
semantic-release-bot a54c525bf6 chore(release): 2.14.1 [skip ci] 2026-09-25 12:10:08 +00:00
Dumitru Picalau 06231cf226 Merge pull request #20 from DumiFlex/claude/project-thread-rxg60x
fix(editor): stop a second cut on an empty field wiping the clipboard
2026-09-25 14:09:36 +02:00
Claude 0a7f4f4386 test(parity): fail CI when engine-parity fixtures are stale; fix schema docs
The vitest parity suite checks the TS validators against fixtures that
scripts/dump_engine_shapes.py writes, but nothing re-ran the script, so a
handler shape change left the fixtures silently stale. A pytest now
re-runs the dump in memory and compares.

CLAUDE.md's schema section pointed at files that don't exist
(import-export/validators/, migration-machine.ts, ENGINE_SCHEMA_VERSION).
It now names the real files and spells out CURRENT vs MAX_KNOWN vs the
content stamp, plus the additive-bump procedure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112KkzW3unDU8PGrTpTDL7r
2026-09-25 11:46:07 +00:00
Claude 82d5bcaa1b feat(schema): stamp schema_version 5 for wildcards with accepts tag axes
A wildcard whose tag_group_kinds marks a group accepts now publishes as
v5 (TAG_AXES_SCHEMA_VERSION) instead of v2, and MAX_KNOWN_SCHEMA_VERSION
moves to 5 in TS and Python. An older extension strips tag_group_kinds
on install, silently turning accepts axes back into classify groups; the
v5 stamp makes it refuse the pack and ask for an update instead.
Additive like v4: no migrator, CURRENT_SCHEMA_VERSION stays 2.

Needs community catalog v5 deployed first (server-first ordering).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112KkzW3unDU8PGrTpTDL7r
2026-09-25 11:43:38 +00:00
Claude 8b6b6ad6d2 fix(community): install downloads at the schema version they were published with
wrapAsEngineExport pinned schema_version 1 and the dependency downloader
pinned 4. installEnvelope migrates from that number, so every community
update re-ran the v1->v2 migration over already-current content, and a
genuinely old dependency would have skipped the migration it needed.
Both now carry the download's own schema_version.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112KkzW3unDU8PGrTpTDL7r
2026-09-25 11:37:24 +00:00
DumiFlex 27943d8da3 fix(editor): stop a second cut on an empty field wiping the clipboard
Backlog P1 "Text input instability on Firefox" asked for a clean repro of
the reported path on the current build: two-option wildcard, add a third,
Ctrl+A Ctrl+X an option into the empty one, twice. Driven in Firefox 153
against 2.14.0 across five variants (cut/paste chains, back-and-forth,
hammered without waits, cut twice in one field). None crashed, bricked the
field or logged an error; the fragment-anchor repairs from 2.13.x hold.

One real defect did reproduce, and it is the content-loss half of the
report. Ctrl+A on an empty field selects nothing but the ZWSP caret pad.
`onHostCut` / `onHostCopy` saw an empty raw selection and returned without
preventDefault, so the browser performed the cut itself and put the pad on
the clipboard, overwriting the text the user had just cut. The following
paste then wrote U+200B into the option value, where it is invisible and
effectively undeletable.

- Cut and copy now swallow a non-collapsed selection that covers no value,
  leaving the clipboard untouched. A collapsed caret still falls through
  to the browser as before.
- Paste strips ZWSPs from incoming data, so a clipboard that already holds
  a pad (from an older build) cannot smuggle one into a value.

Re-measured in Firefox on the rebuilt manager: the double cut now pastes
"red dress" back, and all five variants stay clean.
2026-09-25 13:33:07 +02:00
semantic-release-bot 38f539d400 chore(release): 2.14.0 [skip ci] 2026-08-20 21:11:41 +00:00
DumiFlex bd3fbd54c2 fix(release): drop stray root QA screenshots that broke pack-release
Three QA screenshots (accepts-icon-candidates.png, accepts-mark-live.png,
refchip-card.png) were git-added by accident in fd6642b2 and landed on
main via the tag-axes merge. pack-release.mjs asserts every top-level git
path is either in SHIP_PATHS or DEV_ONLY, so these unclassified captures
failed the 2.14.0 release in the prepare step (no tag or release was cut).

Remove them and anchor `/*.png` in .gitignore: root-level PNGs are always
scratch QA captures here, while every real image lives under public/. The
anchor keeps public/images/** untouched and stops this recurring.
2026-08-21 00:10:32 +03:00
Dumitru Picalau eb18825853 Merge pull request #19 from DumiFlex/feat/tag-axes
Tag axes + v2.14 sweep
2026-08-21 00:04:20 +03:00
DumiFlex 58360c75f8 docs(axes): document tag axes and add the 2.14 release highlight
Wildcard page: correct the accepts fold description (one rolled tag, not an OR
over the menu). Variable-pipeline: $var.AXIS / $var.K.AXIS accessor row.
Constraints concept: an accepts-axis source keys on the rolled tag, an accepts
target OR-folds, and the two agree under a diagonal. Glossary + warning-types:
axis/accepts/classify terms and the two tag-axis warnings. next.md: tag axes
leads the highlights.
2026-08-21 00:01:41 +03:00
DumiFlex 934f7dc92d fix(axes): drive constraints by the rolled tag, fix the assembler read, preserve on import
An accepts axis rolls one tag per pick (read as $var.AXIS), but a constraint
sourced from it folded over the option's whole menu instead of that rolled tag,
so $outfit.SHOES and the constrained $shoes could disagree. The source now
sends only the rolled winner; a target accepts axis OR-folds so a multi-tag
option survives when any alternative is allowed, then its own roll is pinned to
the allowed tag -- $source.AXIS == $target.AXIS under a diagonal. Classify
groups are unchanged (multiply).

The assembler also rendered $var.AXIS empty: __wp_axes__ crosses the socket on
ContextPayload.internals but the node only merged the internal-flag map; it now
merges the whole carve-out so the accessor resolves.

Import: the community validators silently dropped tag_group_kinds (and the
constraint target_select) because the lenient payload object never listed them,
degrading a shared accepts axis to classify. Both are now in the v2/v1
validators + the engine-parity fixtures, with preservation tests. Engine math
mirrored in TS via the shared corpus.
2026-08-20 23:58:44 +03:00
DumiFlex caffb9824f feat(editor): advise when an accepts-axis option carries no axis tag
An accepts axis only yields a value for options that carry one of its tags; an
option with none makes $var.AXIS render empty when it's picked, with no other
symptom. The wildcard editor's sub-category panel now shows a non-blocking
amber note per accepts group when some options don't cover it ("N options carry
no SHOES tag — $outfit.SHOES is empty when it's picked"). Silent when the axis
is fully covered or the group is classify; the null option is excluded (it
deliberately produces nothing). Verified live both directions.
2026-08-19 23:56:29 +03:00
DumiFlex fd6642b258 feat(constraints): mark accepts axes in the matrix
The constraint matrix showed an accepts group (SHOES) exactly like an ordinary
tag group — the editor tells them apart via its accepts button, but the matrix
couldn't. Plumb tag_group_kinds through to the grid (SPA ConstraintMatrix + the
canvas MatrixSection, both wired from their editor/modal) and mark an accepts
band with a small amber pi-sync after the axis name, keeping the group's own hue
so it never collides with an amber group. A hover tooltip and a MatrixLegend row
(shown only when the matrix has an accepts axis) explain the mark. Verified live:
mark on SHOES, none on a classify-only source, legend row appears/​hides with it.
2026-08-19 23:30:00 +03:00
DumiFlex 1cb59b2159 fix(select): size short options to content, and let a small list opt out of filtering
The exception mode dropdown clipped its labels and overlapped the selected
row's tick with a scrollbar. Two real causes, both in Select:

- `.wp-select__option-label` was `flex: 1` (basis 0), so it split the row 50/50
  with the always-present spacer and squeezed a short nowrap option to
  half-width, clipped by overflow:hidden even with room. `flex: 0 1 auto` sizes
  it to content and lets the spacer do the filling; a long option still shrinks
  into the 2-line clamp.
- The "Type to filter" hint showed for any list of 2+, and its height pushed the
  4-item menu past its cap into a scrollbar that overlaid the tick. New
  `filterable` prop (default on) turns the hint + type-to-filter off; the mode
  picker sets it false, so 4 options render as a clean menu — no hint, no scroll.
2026-08-19 19:13:30 +03:00
DumiFlex afa4e47bb8 fix(constraints): stop the exception mode dropdown clipping its labels
The mode Select sits in a table cell that collapses the trigger narrow, and the
Select sizes its menu to the trigger — so the nowrap mode chips clipped to
"N / Exc / Boo / Red". Floor the control at 132px so Neutral / Exclude / Boost /
Reduce each fit. (This is the constraints-mode.mp4 report.)
2026-08-19 19:00:17 +03:00
DumiFlex 1486785df1 feat(refs): make the ref info card readable and actionable
The nested-ref hover card was 10px mono and pointer-events:none — the report's
"unnecessarily small", and un-clickable by design. Bumped to 12px and made it
interactive with a hover bridge (leave the chip, enter the card, it stays — the
GitHub-hovercard pattern), so a footer of actions is reachable: Copy id (with a
"Copied" tick) and Open (the linked module's SPA editor in a new window; full
phrasing in the title). Copy works even for a broken ref; Open is gated on the
ref resolving. The chip's own click is untouched, so no clash with the template
editor's click-to-edit-filter.
2026-08-19 18:58:55 +03:00
DumiFlex b765235f2b feat(spa): sub-category panel default setting, and Send to Test Runner
Two backlog items.

- The wildcard editor's sub-category panel now opens on load per a new
  "Sub-categories panel" display-pref (Settings → Editing): "Expanded when it
  has groups" (default), "Always expanded", or "Always collapsed". Resolved once
  per load so collapsing it and adding a tag doesn't yank it back open. (Also
  dropped a duplicated tag_group_kinds assignment in the load path.)

- "Send to Test Runner" jumps to the runner with a module/bundle preselected via
  ?kind=&module=, which TestRunner reads on mount. Added as an icon row action on
  every list view (and All items, keyed by row.kind) and a labelled button in
  every module editor header. New shared SendToTestRunner.vue.
2026-08-19 07:55:11 +03:00
DumiFlex fac9c4df4b fix(editors): jump to the first error when a save fails
The Save button and the validation banner sit at opposite ends of a long
editor, so clicking Save on a wildcard with 30 options did nothing the user
could see — a reporter clicked it five times before finding the banner off
screen. EditorFrame now watches its `errors` prop and, on the empty→populated
edge (the "tried to save while invalid" moment every editor already gates on),
scrolls the first offending field into view via the existing scrollToField.
Guarded to that one edge so re-validation on each keystroke while fixing it
doesn't yank the scroll. Covers all five editors at once.
2026-08-19 04:56:18 +03:00
DumiFlex 69a855291c fix(spa): carry NSFW rating on copy, and give icon buttons a hover tooltip
Two backlog QoL fixes.

- duplicate_module dropped content_rating, so copying an 18+ module produced a
  SFW copy that slipped out from under the filter. Forward it.
- Icon-only Buttons carried an aria-label (screen-reader name) but no title, so
  a sighted user hovering a bare icon got nothing. Mirror the label into title
  when there's no visible text — fixes every icon button at once.
2026-08-19 04:45:11 +03:00
DumiFlex df252c736a feat(loop): rebind frame chips to Ctrl-bypass / Alt-lock
Shift for seed lock and Alt for bypass didn't match the reflex — users reach
for Ctrl to bypass and Alt to lock. Swap to Ctrl-click (Cmd on macOS, where a
Ctrl-click is a right-click and never reaches the handler) for bypass and
Alt-click for the seed lock; Shift is freed. Plain click still selects (Loop)
or locks (Seed List, no cursor). Legend + tooltips + the three widget test
suites updated. Verified on the live canvas: ctrl→bypass, alt→lock, shift no-op.
2026-08-19 04:31:26 +03:00
DumiFlex 2cf7f7e8c3 fix(axes,internal): fix the inline accessor warn+colour and internal last-write-wins
Two bugs the modal-only testing missed, both verified in the live canvas.

1. Assembler template (and every inline `.wp-rt-var` render) mis-parsed an
   index-first accessor. `varSpanAttrs` hand-rolled the axis extraction with a
   `.replace(/\.\d+$/,"")` that only strips a TRAILING index, so
   `$outfit.0.SHOES` produced axis "0.SHOES", matched no declared axis, and
   painted a valid reference with the unknown-axis warning — the yellow
   squiggle in the screenshot. Replaced with the one shared `varAccessorParts`
   grammar (index + axis, either order), and split the inline tail into a
   neutral index span and an amber axis span to match the settled chip. The
   canvas CSS copy (rich-text-canvas.css) had no accessor rule at all, so the
   amber never applied there — added it.

2. `instance.internal` was add-only in three places (engine pipeline, the
   graph.ts kinds walk, the static resolver), so a var written internal then
   overridden by a PUBLIC module stayed hidden from the assembler forever.
   Made all three last-write-wins: whichever module writes the var LAST decides
   its internal-ness, exactly like its value. Verified both directions live —
   internal→public shows "public value", public→internal hides it.
2026-08-19 03:27:25 +03:00
DumiFlex 0fce54165e fix(chip): stop tinting the pick index like an axis
A var chip's accessor is two unrelated things — a pick index (.0) and a tag
axis (.SHOES) — and both were rendered in one amber span. So `$outfit.0.SHOES`
painted the .0 amber too, and since amber is the axis accent it read as "the
index is an axis / the index is flagged". Worse for the exact case the user
hit: the index only means anything on a multi-pick source, and whether a
source is multi-pick is a runtime fact no static surface can know, so the
editor has no business colouring it at all.

Split the accessor into its own index span (neutral, dimmed, the chip's own
colour) and axis span (amber, or the unknown-axis warning). Only the axis —
the one part that names a library concept — carries a colour now.
2026-08-19 02:38:04 +03:00
DumiFlex f4c8411fb8 fix(assembler): resolve axis reads in the preview, keep engine keys out of the chips
The assembler preview walked the template with a regex that stopped at the
numeric accessor, so `$outfit.SHOES` matched `$outfit`, printed its value and
left ".SHOES" as literal text beside it. It now matches the whole reference
and resolves an axis against the chain's accepts axes — passed down from the
same walk that already feeds the resolved map — falling back to the raw
reference when the chain declares no such axis, so a typo is visible rather
than silently dropped.

Separately: `upstreamVars` is `Object.keys(fresh)`, and the API branch merged
the run ctx in without filtering, so every engine table rendered as a variable
chip. The fallback branch had always filtered; the API branch never did, which
is why the chips appeared as soon as a table existed to leak.
2026-08-19 01:30:12 +03:00
DumiFlex 6aadcf0575 fix(axes): resolve axis reads in the combine preview, stop leaking the tables
The preview tokenizer never carried the axis accessor, so every consumer
rendered the variable and left ".SHOES" beside it as text — which is why both
the PREVIEW and RESOLVED lines read "a white t-shirt and denim skirt.SHOES".
It now carries `axis` the same way it already carried `index`, and the combine
editor resolves an axis read to a tag rather than to the variable's value. An
axis the producer does not declare stays verbatim: swallowing it would delete
text the user typed.

Also undoes the leak from the previous commit. strip_internals' result IS the
variable surface — the assembler's variables panel enumerates it, so keeping
__wp_axes__ / __wp_picks__ there listed them as $__wp_axes__ chips. The tables
are re-attached by with_resolver_tables at the one call site that needs them.
2026-08-19 01:08:30 +03:00
DumiFlex c4e1663210 fix(axes): carry axes across writers, resolve them in previews and the assembler
Three separate places dropped an axis read on the floor:

- The producer map replaced a variable's record wholesale on every later
  write, so a derivation or injector rebinding $outfit erased the wildcard's
  axes. The engine keys __wp_axes__ by binding at pick time and a rebind
  never clears it, so the axes must survive the override. Carried forward in
  both the graph walk and the same-node sibling merge.

- The static preview resolver knew only the numeric .K accessor, so
  "$outfit.SHOES" substituted $outfit and stranded ".SHOES" — the preview and
  resolved lines both read "a white t-shirt and denim skirt.SHOES". It now
  resolves an axis to its first tag, the same deterministic convention @{uuid}
  expansion already uses, and leaves an accessor it cannot resolve verbatim
  rather than deleting text.

- strip_internals runs at the prompt-render boundary and dropped every
  __-prefixed key, __wp_axes__ and __wp_picks__ included. The assembler
  therefore rendered "$outfit.SHOES" and "$outfit.0" as literal text while the
  identical template resolved one node upstream. Those two tables are read by
  key, never as variables, so keeping them cannot leak a $var.
2026-08-19 00:51:50 +03:00
DumiFlex 13acd9a17d fix(rich-text): stop the host swap from erasing the field
Committing an autocomplete suggestion bumps hostEpoch, which replaces the
host element. In a browser that removes the node the caret is in, so the
browser fires blur on the way out — and onHostBlur treated it as the user
leaving: it read the already-detached host as "", saw that differ from the
atoms just applied, and re-applied the empty parse. Every $var commit wiped
the whole template, on the canvas and in the SPA alike.

Flag the swap and let blur skip it. jsdom does not fire blur on removal,
which is why the existing tests never saw this; the new ones dispatch the
blur the browser would, in the window before the swap settles.
2026-08-19 00:28:54 +03:00
DumiFlex a29c0fc564 fix(canvas): expose accepts axes for same-node wildcards
allVarProducers merges the graph walk with siblingVarInfo.producers, and a
variable written in the same Context node takes the sibling entry wholesale.
Only the graph walk carried axes, so `$var.AXIS` completion went missing for
the most common layout of all: the wildcard and the template that reads it
sitting in one node.

Read the axes off the module's own payload snapshot rather than the library,
so the node offers what it will actually run.
2026-08-19 00:02:49 +03:00
DumiFlex 7b820d193d fix(editor): do not flag an axis a stale node snapshot cannot vouch for
A canvas node carries its own payload SNAPSHOT of each module, so a wildcard
added before its group was promoted has tag_groups but no tag_group_kinds at
all. The warning treated that as 'declares no axes' and put a wavy underline
under $outfit.SHOES - a reference that resolves perfectly well, and will look
fine again the moment the node's snapshot is refreshed.

Absence of the key means UNKNOWABLE, not empty. The mark is now claimed only
when the producer declares SOME axes and this is not among them, which is the
one case where the read is known to be wrong. Same rule on both the chip and
the flat-text surface.

Does not fix the missing axis ROWS on canvas: those come from the same stale
snapshot and need the node's module refreshed from the library, which is the
existing drift flow rather than something to paper over here.
2026-08-18 23:52:42 +03:00
DumiFlex dc119ad6bd feat(editor): give the axis accessor one fixed amber
Was the axis group's own hue, matching the pills in the wildcard editor.
That made one meaning look different from variable to variable - SHOES came
out teal in one wildcard and purple in another - when an accessor is the same
KIND of thing everywhere it appears. Now a single --wp-axis token used by both
the chip and the suggestion row.

Amber-400 deliberately, not --wp-warn amber-500, which marks an UNDECLARED
axis: a valid accessor must never read as the warning.

Also restores the accessor style block itself. Both earlier attempts anchored
on a .wp-refchip__label rule that does not exist in this file, so neither the
colour nor the unknown-axis mark had ever actually shipped - the class was on
the element with no rule behind it. Verified in the running SPA: accessor
rgb(251,191,36) against the chip's rgb(34,197,94).
2026-08-18 23:30:50 +03:00
DumiFlex 7e6e373076 fix(editor): keep offering axes once the query carries an accessor
Typing $outfit.0. closed the popover: the flat pool is substring-filtered,
and no entry contains 'outfit.0.'. $outfit. only ever appeared to work by
luck - the pool holds 'outfit.SHOES', which happens to contain 'outfit.'.

From the first dot the question changes from 'which variable' to 'which
axis', so the pool does too: it offers that variable's axes, narrowed by the
fragment after the dot, and preserves a pick index the user already typed
($outfit.0. -> $outfit.0.SHOES). Verified in the running SPA.
2026-08-18 23:02:01 +03:00
DumiFlex e0b4ab0bcc chore: ignore playwright-mcp session artifacts
Browser-automation snapshots and console logs from a QA session; they are
per-run scratch, not source.
2026-08-18 22:56:18 +03:00
DumiFlex 27f6244142 fix(editor): stop a self-emit echo chipifying mid-word text on Backspace
Found by driving the real SPA with Playwright; jsdom could not see it. One
Backspace after typing $mo turned it into a chip, closed the popover and
threw the caret onto the host root - three separate reports, one cause.

The modelValue watcher guards against our own echo with
'next === lastEmittedValue && readHostAsText() === next'. But the
atom-direct edit paths sync the host DOM imperatively AFTER updating the
model, so at emit time readHostAsText() still returned the PRE-edit string.
The guard missed, the watcher re-parsed through parseForSurface, and that
chipifies. emitValue now claims the single round-trip it is about to cause,
released on the next tick so an uncontrolled parent cannot strand the claim.

Also fixed, both verified live:
- the axis segment now takes its group's hue on the chip, not just in the
  suggestion row; it was styled with opacity/weight only and rendered plain
  chip-green. A pick index deliberately stays neutral - it is positional,
  not semantic.
- the stale-results dimming applied to $var popovers, which have no async
  fetch, so rowsQuery never advanced and the list read stale from the first
  keystroke and never recovered.
- the axis row indent lost to a later padding SHORTHAND; now specificity-
  based so source order cannot undo it.
2026-08-18 22:54:46 +03:00
DumiFlex 726cca5d91 fix(editor): stop the atom model discarding var accessors, and mark unknown axes
QA sweep over axis authoring found the real fault under the reported
symptoms: the ATOM model had no accessor at all. VarAtom carried name and
index but no axis, and tokenToChipAtom never copied one, so $outfit.SHOES
round-tripped as $outfit - silent data loss, not a cosmetic issue. My
earlier fix was to inlineTokenHtml, a different layer, which is why the chip
still looked wrong.

Four hand-rolled var serialisers existed and one had already drifted:
parseForSurface wrote '$' + name and dropped a .K index, so $mood.0 lost
its accessor on the assembler surface today, independent of this feature.
All four now route through one serialiseVarAtom.

Also fixed from the sweep:
- the chip label was built from name alone, so the accessor never rendered
- a dangling dot ($outfit.) killed the trigger, closing the popover at the
  moment the axis list was most wanted
- the axis row's padding-left was reset by a later padding SHORTHAND on the
  base rule, so the indent never applied
- an undeclared axis was unmarked on chip surfaces; the warn styling had
  only landed on the flat text path

Two reported symptoms did NOT reproduce and are not fixed here: caret jumps,
and backspace committing a chip. The Backspace handler deletes and
preventDefaults correctly - an early version of this test deleted twice and
manufactured the failure.
2026-08-18 22:17:15 +03:00
DumiFlex 6d19bd94f4 feat(editor): render the accessor as its own segment, and stop flagging valid axis reads
The reference stays ONE .wp-rt-var atom - caret math and deletion are
unchanged - but the .AXIS / .K tail gets its own span so it reads as a
qualifier rather than part of the name.

Also fixes a real bug behind it: varSpanAttrs checked the WHOLE string against
varSuggestions, so $outfit.SHOES matched nothing and every valid axis read
wore the unknown-variable error styling. Scope is a property of the base
variable, so the check now uses the base.

A base that resolves with an axis that does not is warn-coloured rather than
silent, matching the unknown_tag_axis conflict rule. The engine renders an
undeclared axis as an empty string, so without a mark the only symptom is a
word quietly missing from the prompt.
2026-08-18 21:53:07 +03:00
DumiFlex 2e9a5a5b6a feat(editor): offer a variable's axes as rows in the $ autocomplete
Typing 'out' now lists outfit and, indented under it, outfit.SHOES and
outfit.EXPOSES with the tags each will roll from. One Enter commits the whole
reference, so a committed chip no longer seals the variable before you can
reach its axis.

Flattened into the existing list rather than a second picker like @{uuid}
uses: that picker exists because a ref needs a typed filter expression and a
toggle, which cannot live in a list row. An axis is just a name, so a second
popover would be machinery the problem does not call for - and it would tax
every axis-bearing variable, including the times you wanted the plain $outfit.

The member tags are shown because they are what tell SHOES from EXPOSES. The
accessor segment takes its group's own hue via axisHueAt, indexed over ALL
groups rather than the accepts ones so it matches the colour that group wears
in the wildcard editor.

No index rows: pick_min/pick_max are per-instance, so a library-authored
template has no range to offer and any number shown would be true for one use
and wrong for the next.
2026-08-18 21:48:01 +03:00
DumiFlex f487cc82a3 feat(editor): carry a variable's accepts axes to the template editors
Both surfaces already thread varProducers to RichTextInput, so axes ride on
that rather than a parallel map: the canvas reads them from the wildcard
payload during its graph walk, the SPA from the module catalog.

Read from the PAYLOAD deliberately. A group's kind is a library fact shared
by every instance, so completing against it is always correct - unlike
pick_min/pick_max, which live on the instance, so a library-authored
template has no pick range to offer and index completion is not
expressible at all.

Only accepts groups qualify: a classify group is not addressable as
$var.NAME, so suggesting one would complete to a reference the engine
renders as an empty string.
2026-08-18 21:39:19 +03:00
DumiFlex 995dc4d17a fix(editor): stop an axis read rendering as inert text and hijacking tag autocomplete
Two defects, both from the accessor grammar landing without the editor being
told about it.

varBaseName stripped a numeric .0 and nothing else, so outfit.SHOES matched
no known variable and never became a chip - the reference resolved fine at
runtime but looked like dead text while authoring. It now understands every
accessor shape and leaves genuinely invalid ones (two indices, three
segments) alone, since reducing those would claim a reference the engine
will not resolve.

The sigil probe walked back over SHOES, met a dot it did not recognise, and
gave up; the booru-tag probe then took the caret and offered shoe TAGS,
none of which are insertable there. It now skips up to two accessor
segments - one index and one axis, either order - and stops, so dotted prose
is not chewed backwards looking for a sigil.
2026-08-18 21:32:59 +03:00
DumiFlex 393004bc54 fix(editor): keep the group-kind control visible at rest
An earlier version faded the classify capsule in on header hover to keep the
default state quiet. That optimised for ink over discovery: a control you
only see by accident is one most people never learn exists, and this is a
capability nobody is looking for yet.

Every group now states its kind. The dim weight keeps it from competing with
the axis name.
2026-08-18 18:33:49 +03:00
DumiFlex 93a05c948e refactor(editor): make the group-kind control a capsule with a quiet default
The pills this control governs are hue-tinted capsules; the control was a
flat grey square borrowed from the ungroup button, which is the vocabulary
of a utility ACTION. On a piece of state it read as a stray control and the
hover landed as a hard block unrelated to anything near it.

Now the same capsule recipe as the pills - hue fill, 999px, 1px hue border -
so it reads as a property of the axis, and hover deepens the tint already
there rather than adding a second colour. It is also labelled, where an icon
needed a tooltip before anyone knew what it toggled.

A classify group shows nothing until its header is hovered, so the default -
every group that exists today - costs no ink; only the promoted state, which
is real information, is permanently visible. Hidden with opacity rather than
display, so it keeps its place in the tab order and :focus-visible reveals it
for keyboard users.
2026-08-18 18:21:32 +03:00
DumiFlex fe7f12e65d refactor(editor): rebuild the group-kind control from the header's own vocabulary
The first version was a raw <select> with hand-rolled sizing - a 10.5px font,
a 4px radius and a 1px border, none of which exist in the design system. It
tripped both the typography and radius token audits and read as a foreign
form control in a header made entirely of icon buttons.

Now a 24px icon toggle with the same shape and hover as the ungroup button
beside it, tokens throughout, tinted with the group's own hue when promoted.
A classify group - which is every group that exists today - carries no extra
chrome at all; the promoted state is what earns the ink.

Being a toggle also removes the select-reset hack: the button renders from
tagGroupKinds, so a refused promotion can no longer leave the control
displaying a state that was never stored.
2026-08-18 17:58:16 +03:00
DumiFlex 05d53a0c6c fix(wildcard): an excluded pool emits nothing instead of the first option
_pick_weighted returned options[0] when every weight was zero. That looked
like a working pick - a real option, right shape, right place - and stayed
wrong silently until the list was reordered and the choice changed. It hid
both over-narrowed constraints and a wildcard whose options were all
disabled.

Now returns None, which every caller already turned into an empty binding.
That is what the null option means, so the engine had the vocabulary for
this already.

Applies to all three pools that constraints can zero: the chain handler, the
nested-@{} path in the resolver, and the SPA preview. The inline {a|b}
branch weights are left alone - a user writing {0::a|0::b} is a different
situation and constraints never touch it.

Three tests asserted the old behaviour and are rewritten to state the new
one; the constraint_excludes_all_options warning stays, and now
distinguishes an over-narrowed pool from a legitimate null roll.
2026-08-18 17:38:05 +03:00
DumiFlex b55a9794a3 docs(tags): explain what a tag is, and the two kinds
A user who had read the docs several times still expected $tag_name to
work, which was a documentation finding independent of any feature. Tags now
have two kinds, so the obligation got larger rather than smaller.

Teaches $outfit.0.SHOES as the one spelling. $outfit.SHOES.0 is accepted
but undocumented - offering both would hand the reader a choice with no
consequence.
2026-08-17 23:19:33 +03:00
DumiFlex c7c761424a feat(conflicts): flag a var axis read no upstream wildcard declares
The engine renders an undeclared axis as empty, so without a signal the
only symptom is a word silently missing from the prompt. Commonest cause is
a group that exists but still says classify; second is an axis renamed after
the read was written.

Deliberately conservative: the scanner runs per node and receives upstream
variables as names only, never payloads, so a read is checked only when a
wildcard in THIS node writes that binding. Guessing about an unseen upstream
producer would fire on correct workflows, and under-reporting beats a
warning nobody can act on.

The producer map is built before the module loop because a read can sit
above the wildcard that feeds it - resolution is by ctx, not by position.
2026-08-17 23:16:47 +03:00
DumiFlex 1e4398ae29 feat(editor): let a tag group declare whether it classifies or accepts
A select on each group header, defaulting to classify. Only accepts entries
are ever stored and the payload key is omitted when nothing is promoted, so
an untouched wildcard saves byte-identically.

The name check runs here rather than at save: an accepts group is read as
$var.NAME, and hearing about it after the work is done is too late. On
refusal the select is put back explicitly - the bound value never changed,
so Vue has no reason to re-render, and it would otherwise sit showing a
state we just declined to store.

Rename carries the kind across and ungroup drops it, since a kind naming no
group is invalid payload. renameGroup's comment claimed axis names were not
part of any grammar; that stopped being true with $var.AXIS.
2026-08-17 23:09:08 +03:00
DumiFlex f0117d4230 fix(constraints): a multi-tag source no longer excludes its whole target pool
The reported failure: an outfit option tagged sneakers, high_heels and
sandals excluded every shoe in the constrained target, the pool summed to
zero, and _pick_weighted fell through to options[0] forever - looking like a
working pick until the list was reordered.

The kind map is derived from the pick records rather than passed down from a
payload, since a pick only carries an axes entry when its source declared
that group accepts. That keeps combine_constraint_factor pure and makes a
legacy pick with no axes take the unchanged flat-product path.

Also fixes the SPA preview resolver, which mirrors the applier: it dropped
axes when building its per-pick list, so the preview would have shown a
narrower pool than the run actually produced.
2026-08-17 22:58:23 +03:00
DumiFlex 5d910f5f39 feat(derivation): let a condition test a rolled axis
Conditions store condition.var as raw text rather than tokens, so this path
parses the accessor itself via parse_var_reference. _ctx_set is left on
split_var_accessor: an action writing to $outfit.SHOES should still target
the base var rather than pretend to write an axis.

_ctx_has checks the axes bucket rather than ctx, since $outfit.SHOES can
exist while outfit is unbound and vice versa.

Action values needed no work - they already route through resolve_text.
2026-08-17 22:52:52 +03:00
DumiFlex 14edfa3458 feat(syntax): resolve axis reads from the rolled ctx projection
get_axis sits beside get_var as a plain dict lookup over a projection
build_resolve_ctx makes at frame construction, so the resolver stays a pure
consumer with no ctx dependency. Keyed by binding, matching what the
reference addresses.

Branches before the var lookup: $outfit.SHOES must resolve even when
nothing bound $outfit itself, and must not trip unknown_var on the way
past. Never routes through deref_var_value either - an axis value is not a
ListVar, so the SP2a accessor contract is untouched.

A missing axis renders empty and warns unknown_tag_axis rather than
failing, matching how every other unresolvable reference behaves.
2026-08-17 22:48:53 +03:00
DumiFlex 9fe33ab4b2 feat(syntax): accept an axis accessor on a var reference
A reference now carries at most one pick index and one axis, in either
order - $o.0.SHOES and $o.SHOES.0 name the same value. Accepting both is
deliberate: $o.0 is already meaningful on its own, so appending .SHOES is
composition rather than a second syntax, and rejecting it would make the
natural spelling the broken one.

Two mirrored regex alternatives, so .0.1 and .SHOES.BELTS reject by not
matching their second segment and the tail stays literal text - no
validation pass. Both tokenizers normalise to one (name, index, axis)
triple immediately, so nothing downstream learns which spelling was used.

Replaces the var_nondigit_accessor_is_text corpus case, which pinned the
behaviour this changes, with cases for both orders and both nonsense forms.
2026-08-17 21:04:56 +03:00
DumiFlex 2f64f6c7ac feat(engine): propagate rolled axes across nodes and held iterations
A held wildcard early-returns before recording, so its axis vanished on
iteration 2+ and every read rendered empty. Carries the frame-0 roll forward
the way picks already are, but keyed by binding rather than module id, since
that is the namespace a var read addresses.

setdefault semantics, not assignment: a wildcard that never asked to hold
still re-rolls each iteration, which the third test pins by running six
seeds over a two-tag menu.
2026-08-17 20:54:34 +03:00
DumiFlex f4444394e5 feat(wildcard): roll one tag per accepts axis at pick time
Two consumers address a pick differently, so each gets the key it addresses
by. The constraint fold needs a tag-to-axis map and binds by module uuid, so
that rides on the pick entry. A $var.SHOES read binds by variable, so the
rolled winner goes to __wp_axes__ keyed by binding - which also means the
read never inherits the uuid-bucket collision.

Rolled at pick time, never on read: that is what makes two modules, or two
nodes, agree. The draw happens AFTER the option draw on the same rng, so an
existing locked_seed still reproduces its option.

Multi-select rolls per pick in pick order, so $outfit.SHOES mirrors the
shape of $outfit.
2026-08-17 20:49:47 +03:00
DumiFlex b6e6f55cce feat(wildcard): validate tag_group_kinds on the payload
A group may declare itself classify (the default, and what every stored
payload means) or accepts. Only an accepts name has to satisfy the
identifier rule, because only an accepts axis is addressable as $var.AXIS;
a classify group keeps whatever name it has today.

Also narrows the constraint weight multiply with isinstance rather than
sentinel identity, which the newly-typed fold made unprovable.
2026-08-17 20:16:41 +03:00
DumiFlex c811d6bc62 feat(constraints): mirror the accepts-axis fold in the ts twin
Same three phases in the same order as the Python side: an exception pair
short-circuits the pick, unclaimed tags multiply, each accepts axis
contributes its max and an all-zero axis excludes. Graded against the same
corpus so the two cannot drift.
2026-08-17 20:12:41 +03:00
DumiFlex 7cd08f070c feat(constraints): fold accepts-axis tags with max instead of product
An option tagged with several tags from one group means those tags are
alternatives it offers, not statements that are all true at once. The fold
multiplied across them, so any sibling's exclude zeroed the option and a
source carrying three shoe tags excluded every shoe in the target pool.

Tags inside a group the source declared 'accepts' now fold with max.
Everything else still multiplies, and the path is unreachable without
axis_kinds, so a payload predating the feature folds byte-identically -
locked by the corpus case that omits the kind map.

Also replaces the bare object() sentinel with a narrowable class: 'is
EXCLUDE' never narrowed, leaving every 'factor *= r' unprovable.
2026-08-17 20:09:32 +03:00
DumiFlex 1c305e4fb1 Merge remote-tracking branch 'origin/main' into feat/tag-axes 2026-08-12 03:09:26 +03:00
DumiFlex f23a9886d4 feat(assembler): declutter the node, and give model syntax one colour everywhere
The two rows around the variable strip go; the strip itself is untouched, being
the part that earns its space.

The standing hint row ("click → insert $var · Ctrl+click → remove · right-click
→ more") is deleted rather than shrunk, because the chips it describes ALREADY
carry that exact text in their own `title` — it was a permanent row duplicating
something one hover away. Removing it outright would have cost the only clue
that Ctrl+click removes, which is genuinely undiscoverable; it does not, because
that clue was never in the row alone.

Load / Save / Clear become icon-only. Each already had a `title` and an
`aria-label`, so the visible word was the only thing dropped, and three words
were most of the row's width. Kept as visible buttons rather than folded into
the context menu: an affordance you cannot see is one you do not know exists,
which is the mistake the hint row was compensating for.

`<lora:…>` and `embedding:…` now carry one fixed colour each — lavender and
cyan, matching the popover's own section icons — in the editor AND in the
resolved preview. The preview needed its own wiring: those two resolve to
themselves rather than to a variable, so they fell into `literal` tokens and
were the last place the syntaxes appeared uncoloured. It calls the same
`modelSyntaxHtml` the editor does, so the two cannot drift.

The colour is applied INLINE, which two independent facts each force. The SPA's
`rich-text.css` is not loaded on the canvas — the canvas has its own
`rich-text-canvas.css` carrying a "keep in sync" note — so a rule would have
had to be written twice. And these spans come from `v-html`, so they never
receive the `data-v-*` scope attribute a rule in a `<style scoped>` block
needs. The first attempt did it with a stylesheet rule and rendered plain #ddd
on both hosts; caught by reading the computed colour rather than the diff.

Verified on canvas: embedding rgb(103,232,249), lora rgb(196,181,253), `$camera`
still its hashed orange, hint row absent, three icon-only buttons. The preview
half is NOT verified — the helper freezes mid-`<Transition>` in this harness,
where `transitionend` never fires, so the preview never mounts.
2026-08-11 21:36:12 +03:00
DumiFlex 45c7bf50de fix(canvas): toggles take effect immediately, and model syntax gets its own colour
Turning a completion source on did nothing until the page was reloaded.
`completionSourceEnabled` asks ComfyUI's settings store (or localStorage)
directly, and neither is a Vue reactive source — so the `computed` gating the
popover cached on its other dependencies and never re-evaluated. Readers stay
where they are, because they are the single place that knows how to answer per
host; a version counter is now bumped by every writer and read by the gate.
A counter rather than mirroring each value into its own ref: there is one
question here — did any of this change — and nothing to keep in sync.

Both hosts notify: the canvas switches from their `wp-switch` handler, the
native separator boolean through ComfyUI's own `onChange`, and the manager from
`uiStore`'s single `writeFlag`. The first test in the new file asserts the BUG
— a computed over the raw reader going stale — so the fix cannot be quietly
undone.

`<lora:…>` and `embedding:…` now render in their own fixed colours, matching
the `loras` and `embeddings` section icons in the suggestion popover, so the
thing being inserted and the thing on the page read as the same kind. Fixed
per kind rather than hashed like `$var`: there are arbitrarily many variables
and the colour is how you tell two apart, but exactly two of these and they
always mean the same thing, so one colour each is learned once instead of
decoded each time.

Deliberately not a tokenizer kind. `tokenizeRich` is shared with the
engine-parity resolver and the atomic editor model, and neither has any
business knowing ComfyUI prompt syntax — this is a rendering concern and it
stays in the renderer, decorating text tokens on the way out. A half-typed
`<lora:` highlights too, since that is exactly when it is being written.
2026-08-11 21:19:27 +03:00
DumiFlex 8929b2e5cb fix(spa): the suggestion list rebuilt twice on every keystroke
The flicker on the last letter typed, from the screen recording. Two causes,
one of them mine.

The sources settled INDEPENDENTLY, which I chose deliberately so the popover
would never be held at the speed of the slower one. That optimised for a
latency that does not exist — both endpoints are in-process on localhost — and
paid for it with a visible double reflow on every keystroke: tag rows landed
and rendered, then model rows landed, appended their sections and re-capped the
tags, so the list rebuilt twice within a few tens of milliseconds. One settle
now, via `allSettled` so one source failing still shows the other.

Frame-stepping the recording also turned up something worse than flicker. At
2.60s the field and the header both read `lazy` while the third row was `laser`
(from the alias `lazer`) — not a `lazy` prefix at all. Those were the results
for `laz`: the header renders `acQuery`, which updates synchronously on every
keystroke, while the rows arrive from a debounced fetch, so there is a window
where the popover confidently shows the PREVIOUS query's results under the
CURRENT query's label. Pressing Enter there commits a row chosen from a list
that no longer matches what was typed.

The rows are deliberately still not cleared while the next fetch is in flight —
blanking on every keystroke flickers far worse than briefly-stale content — so
instead the popover says so: the header shows "searching…" and the rows dim
while `rowsQuery` differs from `acQuery`. That turns an unexplained reflow into
an obvious "still looking", and makes the stale window visible rather than
silent.

Also moved the GitHub star prompt directly under the hero. It sat at the very
bottom of the dashboard, below the stats, the quick-create row AND the full
recents list, so it could not be seen without deliberately scrolling to the end
of a page nobody scrolls to the end of. The reasoning for putting it last — a
favour belongs below what someone came for — was half right and produced
something invisible; an aside nobody can see is not tactful, it is absent. The
concern behind it still shapes the new spot: ABOVE the hero it was louder than
the product's own welcome, so under the hero keeps the welcome first and the
favour second.
2026-08-11 20:52:28 +03:00
DumiFlex 7523aae41e feat(autocomplete): match names the way people remember them
You cannot find a model by typing its name unless you also reproduce its
punctuation, and nobody remembers where the punctuation fell. `2xko` could not
find `2X-KO_more_-ill_r1` at all — reported exactly that way.

Matching now folds `-`, `_`, `.`, `/`, spaces and brackets out of BOTH sides
before comparing, in three tiers: an exact prefix still outranks a folded one,
which outranks a folded substring. So `2xko`, `2x-ko` and `2XKO` all reach
`2X-KO_more_-ill_r1`, `spritepixel` reaches `8.0-sprite pixel art style by
skormino`, and someone who typed a name exactly is never reordered beneath a
fuzzier match. Free to do here: the model list is tens of entries, not the
tag list's 286k.

Tag search is deliberately left alone — it is a bisect over a sorted array of
286,337 names and prefix-only is what every booru autocomplete does.

Sections were not delivering what I claimed for them. Tags lead, `lazy` matches
twenty of them, and the embedding section existed but sat entirely below the
fold — the user could not filter to their own models without scrolling a list
they were not looking for. The leading section is now capped to 6 rows WHEN a
model also matched, which is what actually makes the others reachable. A query
matching nothing but tags still gets the full twenty.

The LoRA and embedding settings move to the same hand-built switch as the tag
one, for the reason that control exists: a count beside the toggle. "50 LoRAs
found" answers the question a bare switch raises — is there anything for this
to suggest? — and it is the question that otherwise sends people to the manager
to check.

And the pill now reads PrimeVue's own `--p-toggleswitch-*` custom properties
for every dimension, not just its colours. Those are injected when their
component first mounts, which has always happened by the time ours renders,
because our row sits in their dialog among their switches — so at render time
this IS their pill and follows their theme. Aura's defaults remain as
fallbacks. Handle travel became a `calc()` over the same tokens rather than a
fixed 16px, so a differently-sized track still lands the knob flush.
2026-08-11 20:31:14 +03:00
DumiFlex ea16a06991 fix(settings): counts move onto the control row, and the pill matches ComfyUI's
Read off the screen recording rather than guessed at — ffmpeg frames of the
real settings dialog, which is the first time this control has been seen next
to the ones it has to sit among.

THE SWITCH WAS THE WRONG SIZE. 34x18 with a 14px knob, against PrimeVue's Aura
preset of 40x24 with a 16px handle, which is what ComfyUI renders and what the
frames measure. Beside three real toggles it read as visibly smaller and
duller.

IT WAS ALSO IN THE WRONG PLACE. The note and the switch were appended in that
order inside a flex row, so the control floated mid-row while every neighbour
sat flush against the right edge. A row that does not line up reads as broken
before it reads as ours. Note first, switch last.

AND THE KNOB TELEPORTED. `paint()` rewrote `style.cssText` on every change,
which replaces the `transition` declaration mid-flight — the browser then has
nothing to animate between. That is the visual glitch: the static styling is
now written once and only the properties that actually change are touched, so
the slide runs.

Colour and timing come from `--p-primary-color` and `--p-transition-duration`,
which ComfyUI publishes on `:root`, so both track their theme. The rest of
PrimeVue's `--p-toggleswitch-*` tokens are injected lazily when the component
first mounts and do not exist until the dialog has been opened once — so they
are read with fallbacks rather than relied on. Still not borrowing
`.p-toggleswitch` itself: those are ComfyUI's internals, and reusing them buys
a perfect match today and a control that renders as nothing the day they
restyle.

In the manager, the counts move onto the control's own row as a tinted pill
pushed to the far edge. First they were appended to the dim `hint`, where the
one fact worth reading was the least readable thing on the card; then they got
their own line, where they crowded the explanation above them. The whole badge
is tinted rather than just its dot — at 11px a dot alone is easy to miss, and
"none found" is the case that most needs noticing.

Verified live: badge on the same row as its toggle, pushed right, no overlap
with the hint, green tint, "50 found" and "17 found".
2026-08-11 19:57:56 +03:00
DumiFlex ef30b366ce fix(settings): a real toggle for the tag setting, and counts you can read
Three things from a QA pass on the restarted server.

The model counts were appended to the `hint`, which is dim by design — so the
one fact on the card worth reading was the least readable thing on it. Each
source now gets its own status line at normal text colour with the same
green/amber dot the tag card uses, so "is this usable?" reads identically on
both cards.

The tag setting kept rendering a checkbox while every other boolean in the
panel is a pill switch. It cannot be a plain `type: "boolean"` — it disables
itself when no tag list is installed, and a native boolean has no way to say
"on is not available" — so it gets a switch of its own: 34x18 pill, sliding
knob, `role="switch"`, space and enter, dimmed and non-interactive in the
disabled state.

Styled inline and namespaced rather than borrowing PrimeVue's classes. Those
are ComfyUI's internals; reusing them buys a pixel-perfect match today and a
control that silently looks like nothing the day they restyle or upgrade.

Typing a bare `<lora:` offered nothing. The endpoint refuses an empty query so
that a stray keystroke cannot dump the whole models folder into a popover —
correct for a bare word, and exactly wrong inside a reference, where the marker
IS the request and "show me what I have" is the main way these get used. The
client now sets `all=1` only when the caret is inside a reference, where the
intent is unambiguous, and the server lists the first N by name.

QA notes from the same pass, all verified against the live server:

- `/wp/api/models/*` serves: 50 loras, 17 embeddings.
- The data-folder migration ran and logged its own decision:
  "left in place — wildcard-pipeline.db (already present at destination)".
  The destination already held the real 1.13MB library, so the never-overwrite
  rule preserved the older loose copy instead of clobbering it, and the server
  resolves to the correct file. Working as designed.
- The two reported completion failures now return exactly one correct match
  each: the completed `embedding:style\lazyhand-e12c.safetensors` path, and a
  LoRA name carrying both spaces and dots.
- Sections render: "tags · embeddings" with per-source headers, tags first, and
  no header when only one source has hits.
2026-08-11 19:39:51 +03:00
DumiFlex 70bd479b83 perf(extension): stop polling a hidden tab, and stop shipping ComfyUI's Inter
Both halves of the low-end-hardware note, measured first as it asked.

PROFILE. An 8-node Context chain carrying 96 variables costs 0.591ms per
refresh. That is small alone and it is not what it costs: every node walks its
WHOLE upstream chain, so a series chain — the shape people actually build — is
quadratic in total work, and nine nodes already produce a visible long task
when the refreshes are forced back to back.

Two loops were paying it forever: `reactive.ts` at 400ms per WP node, and
`subgraph-badge.ts` at 800ms per SubgraphNode, each rescanning regardless of
whether anything could have changed. Neither stopped for a hidden tab and
neither backed off.

They now share `startAdaptivePoll`, which was extracted rather than written
twice — the two had already drifted into having nothing in common but the bug.
It gates on two things that both cost nothing while the user is working:

A hidden tab does no work at all. Nobody can observe a stale widget on a tab
they are not looking at, so this was work being done precisely when it was
least useful. One catch-up refresh runs on the way back to visible, so an edit
made elsewhere is never missed.

A still canvas backs off 3x then 8x after ~4s of no pointer, key or wheel
activity AND no observed change. The fallback exists for upstream edits that
fire no event, which cannot happen while nobody is editing. Any real change or
any user activity snaps it straight back. One shared document listener feeds
every node — N listeners for a single global fact would repeat the mistake the
per-node intervals already make.

The event paths are untouched: `onConnectionsChange`, the mode intercept and
graph load all still fire instantly. Only the fallback slows, and only while
nothing is happening.

FONTS. We shipped a 48 KB Inter that ComfyUI already serves, declared
identically — same family, same `font-weight: 100 900` variable axis, same
latin woff2, same `font-display: swap` — from its main stylesheet, so it is
loaded on every page a canvas widget can appear on.

The note wanted the removal risk measured before taking this, so: added
2025-09-16 in Comfy-Org/ComfyUI_frontend 4ff18fd ("Add Inter font used by
Figma designs", #5535) and untouched since — one commit in its entire history.
It is the typeface of their whole UI, so dropping it means restyling their
product rather than removing an optional asset.

The coupling is also weaker than the note assumed. We depend on the FAMILY
NAME, not their URL: our stack names `Inter` and the browser resolves it
against whatever `Inter` face is loaded, so a rename, re-subset or re-hash is
invisible to us. Their copy lives in `public/fonts/`, which Vite serves
un-hashed exactly so URLs stay stable. And the failure mode is the one the
stack already shows on every cold load — fall through to `-apple-system`.

JetBrains Mono stays; ComfyUI ships no monospace face.

Verified live: zero requests for our Inter, their `inter-latin-normal.woff2`
loaded, `document.fonts.check("16px Inter")` true.
2026-08-11 19:14:19 +03:00
DumiFlex 4b2a76e72b fix(spa): stale textarea styling produced a second scrollbar
Three leftovers from fields that used to be `<textarea>` and became
RichTextInput in ca87384b and 8aea38dd. The markup changed; the CSS written for
the textarea stayed, still matching because the class moved onto the
component's root.

`.row__value` declared `overflow-y: auto` and `max-height` on that root, and
the component's own `.wp-rt__host` already declares both — so a capped,
scrolling box sat inside a capped, scrolling box and a fixed-values row showed
TWO vertical scrollbars. Reported in the bundle snapshot editor, but it was
every fixed-values row everywhere. `min-height`, `line-height` and
`white-space` were the same mistake without a visible symptom. Only
`word-break` survives — a long unbroken value is a row-layout concern, not an
editor one.

`.row__grip` and `.row__value-wrap--more::after` reimplemented the drag grip
and the overflow fade, which existed here only because a textarea cannot carry
either. Both now belong to RichTextInput, and the markup that used these went
with the textarea — the rules had been matching nothing since.

`.ibm__template` kept `resize: vertical`, which put the browser's native
resizer in the same corner as the component's own grip: two handles, one of
which the component cannot clamp. That is the class of bug `startResize` exists
to avoid.

Also from the same report: the SPA's template editor was a plain Textarea, so
it was the one place a PromptAssembler template could be written with no tag,
LoRA or embedding completion at all — despite templates being saved here and
loaded into the assembler. It uses RichTextInput on the `assembler` surface
now, so both ends agree on what the syntax means.
2026-08-11 18:58:01 +03:00
DumiFlex e8b75a11a9 feat(autocomplete): understand lora and embedding syntax, and finish the settings
Four things, all of them the same feature being genuinely usable rather than
merely present.

The completion could not see a filename. `probeTagWord` bounds a word with the
character class `[a-zA-Z0-9_'-]`, which contains no dot and no path separator —
so a caret at the end of `embedding:style\lazyhand-e12c.safetensors` walked back
only as far as the dot and searched for `safetensors`, matching every model on
disk and nothing that was meant. Real names carry dots, separators and spaces
(`8.0-sprite pixel art style by skormino`), and no character class can bound
that.

So the new `probeModelRef` bounds by SYNTAX instead: everything between
`<lora:` or `embedding:` and the caret. Unambiguous, and it describes names a
class never could. It stops at a comma, newline or `>` so a completed reference
earlier in the prompt cannot claim a caret that has moved on, and past a LoRA's
second colon, which is a weight and not a name.

It also takes precedence over the bare word and RESTRICTS the search to the one
kind being named — a reference names exactly one by construction, so offering
the other, or offering tags, was offering something that could not legally be
inserted there. That is the "caret in the embedding, popup full of LoRAs"
report. Committing inside a reference now inserts the bare path, since the
marker is already typed.

Server side, the query is separator-normalised like the stored paths, and a
full path now ranks as a PREFIX hit — otherwise a completed reference sorted
below unrelated substring matches.

The canvas settings use plain `type: "boolean"`, which is what renders
ComfyUI's own pill toggle; the checkboxes came from the hand-built renderer.
The tag setting keeps its custom control because it DISABLES itself when no
list is installed, which no native type can express — the two model sources
have no such state, so there is nothing to express.

New opt-in setting appends `", "` after a completion. Suppressed inside a
reference, where a comma would terminate the thing being completed. Applied on
both the keyboard and mouse commit paths, because a preference that lands on
one of them is worse than not having it.

And the gap I left last time: the model sources had no SPA surface at all —
canvas settings and playground mirrors only, with nothing anywhere writing
their storage keys, so outside the canvas they could not be switched on.
`ModelSourcesCard` adds the toggles, the counts, and the Refresh that
`/wp/api/models/refresh` was built for and nothing called. `uiStore` gains the
writers; `tagSetting.ts` stays the reader, because the canvas has no Pinia.
`enabledCompletionSources` is deleted — it was referenced only by its own test.
2026-08-11 18:49:38 +03:00
DumiFlex cce1ac4dae fix(canvas): the node corner is the only way to size the template editor
Maintainer call after three failed rounds: drop the in-editor grip, let
ComfyUI's node corner own the height, and make the editor scroll instead of
growing to fit its content.

That is the right call and my argument against it was wrong. I rejected
`fillHost` on the grounds that the assembler has TWO widgets, so
node-drives-widget would stretch the chip strip and preview alongside the
editor. Measured, it does not — the helper carries `autoHeight`, which pins it
to its content, so every pixel of slack goes to the editor:

    node 390 -> editor  70, helper 178
    node 700 -> editor 168, helper 178
    node 1000-> editor 273, helper 178
    node 300 -> clamps to 420, editor back to 70

Shrinking works again too: 1000 -> 300 clamps at the widgets' legitimate 420
floor rather than springing back to fit the text, and the box scrolls
(scrollHeight 928 against clientHeight 98) instead of inflating the node.

The root cause of all of it was two resize authorities over one box. Each round
found and fixed a genuine defect — `autoHeight` discarding the user's height,
grip-follow mutating scroll from inside the resize flush, a lifted height cap
outliving its drag, a lost `pointerup` stranding `dragging` — and none of them
was the symptom, because arbitrating between two authorities cannot make them
agree. Removing one can.

`fillHost` also skips the content-size ResizeObserver entirely and takes the
subtree out of flow, so the template can no longer push the node at all. That
is the auto-scaling half of the report.

New `fill` prop on the editor: hides the grip, drops the 14rem cap (which
exists to bound AUTO-growth, and would otherwise cut a node the user
deliberately dragged tall), and sets `min-height: 0` on both the root and the
host. That last part is load-bearing — both are flex items, and a flex item
defaults to `min-height: auto` and refuses to shrink below its content, which
is exactly how the payload used to push the node.

The grip stays everywhere else. The SPA's modals and pages have no node corner
to drag, so there the in-editor handle is the only control there is — and the
stuck-drag fix in 25527c37 still matters for them.
2026-08-11 17:55:10 +03:00
semantic-release-bot 27cbf5fa26 chore(release): 2.13.2 [skip ci] 2026-08-08 23:12:52 +00:00
Dumitru Picalau fc920b4d3c Merge pull request #18 from DumiFlex/fix/editor-crashes-and-release-integrity
fix: Firefox editor crashes, library version guard, and release archive completeness
2026-08-09 02:12:08 +03:00
DumiFlex d604deb6a5 docs(release-notes): curated highlights for the editor and library fixes
`.releaserc.js` renders no commit sections (`transform: () => false`), so
this file IS the release body. Without it the release ships with only a
header and a compare link — the blank-changelog outcome that 2.13.0 hit.

Written for users rather than maintainers: what broke, what it looked
like, and which installs were affected. semantic-release clears the file
on release, so it leaves no net change on main.
2026-08-09 01:59:00 +03:00
DumiFlex 8cf40241cf fix(db): record migration checksums and refuse a foreign schema
`migrate()` skips everything `<= MAX(version)`, which made an integer
the entire identity of a schema. It is not, and two failures followed
from that, both silent:

1. A database migrated by a NEWER build. Every migration is already
   "applied", so the runner does nothing and the caller reads a schema
   it has never seen. Additive columns survive; a shape change does not.
2. A database at the SAME version but a different shape, because a
   migration file changed after it had already run somewhere — a
   revised migration, a hand-edited file, an abandoned branch. Version
   numbers match, so nothing compared anything.

Both corrupt cumulatively: every subsequent write goes through code
whose assumptions about the row shape are wrong, and the user has no
signal until something much later fails for an unrelated-looking reason.

Migration 017 adds a nullable `checksum` column; the runner records the
sha256 of the file it actually executed. `assert_schema_compatible`
checks both failure modes and runs at the top of `migrate()`, so no
caller can forget it.

NULL is treated as "unknown", never "mismatched". Rows applied before
017 existed cannot retroactively grow a checksum, and backfilling them
from the files on disk would assert exactly the compatibility the check
exists to verify. So the version-ahead half protects every database
immediately; the divergence half protects migrations from 018 onward.
2026-08-09 01:52:47 +03:00
DumiFlex 72e96ec73f fix(release): ship locales/ in the release zip, and make the ship list total
`SHIP_PATHS` omitted `locales/`, which ComfyUI reads from
`custom_nodes/<pack>/locales/<lang>/` for node display names,
descriptions and settings labels (app/custom_node_manager.py). We ship
main.json, nodeDefs.json and settings.json there.

The registry archive already included it, because `comfy node publish`
ships everything git-tracked that `.comfyignore` does not exclude. The
zip is a hand-maintained list, so the two artifacts silently disagreed
about what a complete install is. Nothing installs from the zip today,
which is the only reason this has not bitten anyone.

The omission is the symptom; two hand-maintained lists of the same thing
with nothing tying them together is the cause. `assertShipListIsTotal`
now fails the pack when a top-level tracked path is neither in
SHIP_PATHS nor explicitly listed as dev-only, so the next runtime
directory someone adds cannot go missing the same way.

Also names the missing-`zip` case: on Windows the packer reported
"exited with status null", which says nothing about `zip` not being on
PATH.
2026-08-09 01:52:46 +03:00
DumiFlex 5d593a1700 fix(editor): repair the host whenever the fragment anchors are gone
Second report, same wound, different door: Ctrl+A then Ctrl+X inside an
option value bricked the field on Firefox — typing, Backspace and even
Save and Cancel stopped responding — with `insertBefore: Child to insert
before is not a child of this node` and the `g is null` unmount cascade.

`onHostCut` already routes the deletion through `applyAtoms` so the
browser never removes the spans itself (2.13.1). But the atom list still
shrinks from N to 1, and shrinking a keyed list unmounts the surplus
`<template v-for>` Fragments. With the fragment anchors normalised away
by Firefox that teardown walks off the end of the child list, exactly as
the ref-picker path did — reached from a call site nobody had flagged.

Marking individual call sites `rebuild` was a whitelist, and the
whitelist kept missing entries: three separate crashes now, three
separate patches. Ask the DOM instead.

`hostAnchorsIntact()` counts the empty text nodes Vue uses to delimit its
Fragments — a healthy host holds exactly `2N + 2` of them, verified
against the live bundle. `isStructuralApply()` marks the applies that
add, remove or re-branch an atom, which are the only ones that touch an
anchor. When both hold, the host is replaced instead of patched, and all
thirteen `applyAtoms` call sites are covered at once.

Costs nothing on Chromium, where the anchors are never missing, and
nothing on the same-shape applies that carry ordinary typing — a test
pins that a chip settle still lands on the very same host element.

Save/Cancel and Backspace needed no separate fix; both were the dead
subtree left behind by the first throw.
2026-08-09 01:52:45 +03:00
DumiFlex 81495ea530 fix(editor): stop the ref picker killing the field on Firefox
Inserting a nested `@` reference took the whole editor down behind the
error boundary with `can't access property "nextSibling", e is null`,
followed by the unmount cascade (`g is null`). Firefox only.

`<template v-for>` mounts each atom inside a Fragment, and Vue delimits a
Fragment in the DOM with two EMPTY TEXT NODES. Those anchors sit inside
the contenteditable host, which the browser owns — and Firefox normalises
empty text nodes away as the user types. Chromium keeps them, which is
why this never reproduced here.

The picker-Apply path then re-keyed the atom v-for to force a clean
remount. Vue's `removeFragment` walks `nextSibling` from the start anchor
until it reaches the end anchor; with the end anchor gone the walk runs
off the last child and derefs null.

`hostDomIsStale()` could not catch it: it counts `[data-atom-index]`
ELEMENTS, and what the browser destroyed were anchors, so it reported a
healthy DOM and the re-key path was taken.

Both rebuild routes now go through the host ELEMENT swap that already
existed for the stale-DOM case. Unmounting an element vnode is a single
`hostRemove(el)` on a node that IS still attached, and its children come
down with `doRemove: false`, so no missing anchor and no detached child
el is ever touched; the new tree then mounts into an empty element with
nothing to insert before. The v-for key drops back to the plain index.

A host swap also replaces the element the ResizeObserver was watching, so
`useGrowableField.attach()` is now idempotent and RichTextInput re-attaches
on every swap — otherwise the overflow fade and grip-follow died silently
for the rest of the session on any field that had ever repaired itself.
2026-08-09 01:52:45 +03:00
DumiFlex 25527c37ff fix(canvas): a lost pointerup left the resize drag stuck to the cursor
Reported as "the resize is linked to my mouse and resizes as I move my mouse
around without holding, and can't be interrupted no matter what I do or press."

That is one state — `dragging` stuck true — and it was reachable whenever a
single `pointerup` was missed. Three things combined:

`setPointerCapture` reads like the right call and is what stranded the drag.
Capture retargets every later pointer event to the grip, INCLUDING `pointerup`.
The editor re-renders while it resizes, and a captured element that gets
replaced is detached — so the `pointerup` was delivered to a node no longer in
the tree and never bubbled to `window`. Removed: the listeners are on `window`,
which sees the events regardless of what is under the cursor, so capture bought
nothing here.

The listeners were in bubble phase, so a `stopPropagation()` anywhere between
the target and the window hid the release too. ComfyUI's node layer does exactly
that to stop the canvas panning under a widget. All three are capture phase now,
which fires before anything downstream can swallow them.

Nothing ever cleared `dragging` afterwards, which is the "can't be interrupted"
half — there was no path back to a stopped state at all. Now `pointerdown`,
`blur` and Escape all end a drag, and `onResizeMove` bails when `ev.buttons` is
0. That last one is the one that cannot be outsmarted: every pointermove reports
which buttons are down, so a move with none held means the release was lost
whatever swallowed it. It ends the state even for a cause we have not found.

Two existing tests drove a drag with a synthetic `PointerEvent` whose `buttons`
defaults to 0, which the new guard correctly reads as a release. They now pass
`buttons: 1`, which is what a held drag actually reports and what they meant to
simulate.

This is separate from bc8421fe. Those three fixes are in and correct — they
addressed the node fighting the editor over height. This is the drag never
ending, which no amount of height policy would have cured.
2026-08-07 17:19:33 +03:00
DumiFlex 09257471f8 feat(autocomplete): lora and embedding sources, in sections beside the tags
Two more completion sources for a bare word, each behind its own setting.
Far cheaper than the tag list: ComfyUI already enumerates both folders for its
own node combos, so there is nothing to download, cache, byte-cap or parse, and
the security surface that dominated the tag work does not exist here.

Sections rather than one ranked list, because the sources have no common
ranking key. A tag ranks by post count in the tens of thousands; a model has no
count at all — you either have the file or you do not. Any flat ordering has to
invent a comparison between "12,000 posts" and "a file on disk", and whichever
is invented, tags win on weight of numbers and the user's own models sink below
the fold. Sections also let each source cap its own rows, so 20 tags cannot
crowd out 3 matching LoRAs.

The rows stay ONE FLAT ARRAY internally even though they render grouped,
because `acActive` indexes it. Grouping the state would put the keyboard
selection in charge of two coordinates and make "skip the header" a case to
handle; flat, a header simply is not in the array and cannot be selected. The
header also only appears when more than one source has hits — with a single
group it would cost a row to say what the group already says.

A model row inserts its FULL RELATIVE PATH, not the display name. Two folders
can hold the same filename and ComfyUI resolves by path, so inserting the short
name would silently pick a different file from the one on screen. The folder is
shown as the row subtitle for the same reason.

Icons are `pi-asterisk` for lora and `pi-code` for embedding — the only two
candidates from the shortlist with zero existing uses in `src/`. That check is
the method: `pi-tags` was chosen for booru tags precisely because `pi-tag`
singular is already the fixed_values module kind.

Each source is independently switchable. Someone writing danbooru prompts may
have no LoRAs at all; someone with 200 of them may find the tag list noise.
`tagSetting.ts` generalises to answer per source, keeping its host-aware split —
the canvas reads ComfyUI's settings store, the SPA reads localStorage directly
because this is called on every keystroke probe.

The settings parity test earned its keep again: it failed the moment the two
new canvas settings existed without playground mirrors.

Sources settle independently rather than being awaited together, so the popover
is never held at the speed of the slower one — the model lookup is an
in-process list scan, the tag lookup walks a multi-megabyte index.

The new routes need a ComfyUI restart before they serve. Verified that until
then the 404s degrade silently: settings persist and read back, tag suggestions
are unaffected, nothing throws.
2026-08-07 15:52:07 +03:00
DumiFlex 09b0e0ddd3 fix(canvas): template variables are actually text now, not restyled chips
The previous commit only restyled the chip — box off, colour on, atom intact —
and that is not what "coloured text" means. It still behaved like an object:
one Backspace deleted the whole token, the caret could not enter it, it kept a
pointer cursor and its leading `⌘` glyph. Looking like text is not being text.

The mechanism for this already existed and I missed it. `parseForSurface` has a
per-surface COLLAPSE SET that keeps a token as literal text instead of turning
it into an atom — `wildcard` collapses `var`, `fixed_values` collapses both. The
assembler simply was not in it. Adding it there makes `$name` ordinary editable
text, which is the whole fix: caret enters it, Backspace takes one character,
no glyph, no atom.

Colour then comes from `inlineTokenHtml`, which already emits editable
`<span class="wp-rt-var">` sub-spans — it was being told to collapse `var` to
bare text. `textAtomHtml`'s always-collapse rule has a documented rationale
("the ABSENCE of a chip already signals not-committed, so an inline colour
would only compete with the settled chip palette"), and the assembler is the
one surface where it does not hold: there are no var chips there at all, so
nothing is being competed with and the colour is the only signal.

Per-variable colour rides on an inline `style` from a new `varAttrs` hook,
because `.wp-rt .wp-rt-var` already sets a colour at higher specificity than
the global `.var-N` palette classes. Unbound names keep the danger colour and
wavy underline, still only claimed where the host walked a graph.

The hand cursor was unrelated to any of this and is fixed separately: nothing
ever set a cursor on `.wp-rt__host`, so it inherited from the surrounding
chrome — on the canvas that is ComfyUI's node wrapper carrying `cursor-grab`
for dragging. A grab hand over an editable field reads as "you cannot type
here". Never visible in the SPA, which is why it survived.

`RichTextPreview` gets the same surface rule, or the same template would render
with chips read-only and without them in the editor.

`RefChip`'s `flat` prop and its test are deleted rather than left in place —
with vars collapsed to text on the only surface that used it, the whole path is
unreachable. One insert test asserted the assembler chipifies, which this makes
false by design; it now asserts the rendering that replaced it, with a sibling
case proving chipping surfaces are untouched.

Verified live: 0 chips, cursor `text`, `$quality` → `$qualit` on one Backspace,
colours unchanged from the strip.
2026-08-07 15:26:15 +03:00
DumiFlex bc8421fed3 fix(canvas): the template editor stopped fighting the node over its height
Three defects, each independently wrong, all pushing on the same thing. Keeping
the grip rather than moving to `fillHost`: the assembler has TWO widgets, and
`fillHost` means the node's size drives the widget — with two of them sharing
the node height, dragging the corner would stretch the chip strip and preview
too. Debug gets away with it because it is the only widget on its node.

`autoHeight` was copied onto the template editor from the Context and Injector
widgets without their reason for it. Those need it because a node stuck at a
manually-set tall height breaks their collapse animation; this widget has no
collapse animation. And the flag is precisely the branch that discards a user's
height:

    const userControlsHeight = !options.autoHeight && …
    const rawTargetH = userControlsHeight ? Math.max(cur[1], min[1]) : min[1];

With it off, the default path already does what a template editor wants —
preserve the height the user settled on, grow only when content needs more. The
mechanism was there; the flag was switching it off.

`followGrip` now runs only while a grip is actually held, which is what its name
always implied — it exists to keep the handle under the cursor. Ungated it also
fired on content-driven growth, so typing a long enough template scrolled the
page out from under you, and it made the resize flush mutate scroll position.
That moves the element the observer is measuring: a resize handler that can
change its own input is the shape a runaway needs.

`capLifted` now re-arms on pointerup. It guards a one-time "pin the current
height, release the max-height cap" step, and leaving it set meant the release
outlived the explicit height that bounded it — `applyAtoms` replaces the host
element on a rebuild, dropping the inline height but not the flag, so the next
drag found an uncapped, unpinned box.

Both new tests confirmed to fail with their fix reverted. Still not reproduced
end to end: the harness here runs the page as a hidden tab where neither rAF nor
ResizeObserver fires, so the drag cannot be exercised. Each change stands on
inspection; which of them was the reported symptom is for a real pointer to say.
2026-08-07 15:12:42 +03:00
DumiFlex 5c164bb6ee fix(spa): the overflow fade measured text in the wrong font
The "sometimes the highlight at the bottom of an input doesn't render" report.
Deterministic once the font is identified as the variable.

These fields are typed in `--wp-font-mono`, a bundled webfont declared
`font-display: swap`. So the first paint lays the text out in the system
fallback (Consolas / Monaco), the real face swaps in later, and the two stacks
have different metrics. `useGrowableField` measured `scrollHeight` once at
mount, via a single rAF — against type that was about to be replaced — and
nothing re-measured after the swap.

That produces exactly the reported intermittency: wrong on a cold load, correct
on a warm cache where the face is already there, and corrected by accident by
any later scroll, input or resize, since all three re-measure. Nothing in the
codebase waited on `document.fonts.ready` anywhere.

`attach()` now re-runs the overflow hint and the autosize once fonts settle.
Resolves once, no-ops when the font was already cached, and guarded for hosts
that expose no font-loading API.

Stated plainly: this is a strong hypothesis, not a confirmed reproduction. The
harness available here runs the page as a hidden tab where rAF never fires, so
the fade could not be observed either way. The mechanism is real and the fix is
correct regardless of whether it turns out to be the whole of what was seen.
2026-08-07 14:59:07 +03:00
DumiFlex 28487f1a68 feat(engine): database and tag list move into user/wildcard-pipeline/
Both files sat loose in ComfyUI's user directory next to ComfyUI's own. That
was fine with one of them and stopped being fine at two. One folder is also one
thing to back up and one thing to delete.

The subfolder alone would have read as data loss: every existing install has a
real library at the old path, and the extension would have come up empty while
the file sat one level up. So `engine/db/relocate.py` moves them in, once, on
first boot.

Four constraints shaped the mover:

Never overwrite. A file at the destination means either a finished migration
or something the user put there; both outrank anything we could infer, so the
legacy file is left alone and reported as skipped.

Run before the database is opened. SQLite keeps `-wal` and `-shm` sidecars
beside the main file and moving that set out from under an open connection
corrupts it. The call sits in `__init__.py` ABOVE the node imports, which can
reach the DB — placement is load-bearing, not stylistic.

Move the sidecars with it. A `-wal` holds committed transactions not yet
checkpointed into the main file, so moving the `.db` alone silently drops the
most recent writes.

Never raise. This runs at extension import; a throw would take the whole
extension down over housekeeping. Failures are collected into a report the
caller logs, and the degraded state is "keep using the old location".

`shutil.move` rather than `Path.rename` because a user directory that is a
mount or a symlink elsewhere would make rename fail with EXDEV.

`TAG_FILE_NAME` is duplicated in `relocate.py` rather than imported from
`wp_api/tags.py`: the directory contract keeps `engine/` free of ComfyUI-facing
imports, and that module pulls in aiohttp.

Five existing path assertions moved to the new location. They were asserting
the behaviour this changes, and now carry a note saying why the subfolder is
expected so nobody "fixes" them back.
2026-08-07 14:50:17 +03:00
DumiFlex 91e426b595 feat(canvas): assembler variables are coloured text, and the popover matches
The template editor and the variable strip under it disagreed about what
colour a variable is. `.wp-refchip--var` painted every var the same
`--wp-success` green; the strip ran each name through `varColorClass()` and got
eight. So `$quality` was green in the editor and blue in the strip, six pixels
apart, in one node.

Chips go, on this surface only. A border and a fill advertise "this is an
object you can act on" — true in the module editors, where clicking a ref chip
re-picks its wildcard, and false in a prompt template, which is prose you are
writing. Font size steps up to the surrounding text too: chips ran 10px inside
12px because a box has to sit within the line, and without the box a token a
size smaller than its own sentence just reads as misaligned.

Refs keep their box everywhere. A `@{uuid}` really is a clickable object with a
picker behind it.

The colour is applied inline rather than via the `.var-N` class. Those live in
`theme.css` at the same specificity as `.wp-refchip--var`, and this component's
scoped block is injected after it, so the class lost the cascade and every
token still rendered green — caught by measuring the computed style rather
than by reading the diff.

Dropping the box cost the one cue that separated a broken var from a working
one: with a chip, `$typo` still read as a chip. `resolved` cannot recover it
(for a var it is only `name.length > 0`, so a typo is "resolved"), so the state
comes from `inScope`, and only where the host actually walked a graph — in the
SPA every var is out of scope because there is no graph to be in. Unbound vars
get the danger colour and a wavy underline instead of a palette colour.

The `$` popover now takes the variable's colour rather than its module kind's,
so a row and a strip chip can be matched by looking. The glyph still carries
the kind, exactly as the strip does. `@` rows keep `kindTint` — there the kind
IS the identity of the thing being picked.

Verified live: `$quality` rgb(147,197,253), `$camera` rgb(253,186,116),
`$scene` rgb(249,168,212), identical in editor and popover, and `$typo_here`
red with a wavy underline.

Note on the preview I sent: it proposed raising the editor line-height from
1.55 to 1.75. The real value is already 1.9, so that part was wrong and is not
applied. The strip's gap and padding do move onto the `--wp-space-*` scale.
2026-08-07 14:41:26 +03:00
DumiFlex 17cefad53c fix(spa): a failed pointer capture killed the resize drag entirely
`startResize` called `setPointerCapture` before attaching its `pointermove`
and `pointerup` listeners. That call throws `NotFoundError` for any id with no
active pointer, and an uncaught throw there meant the listeners were never
attached — a grip that visibly does nothing when dragged.

Capture is only an optimisation here: it keeps move events coming when the
pointer outruns the 16px grip. The listeners are on `window` and work without
it, so a failure should cost nothing. Wrapped, and left after the listener
attach path so ordering can't matter again.

Found while trying to reproduce the reported resize bugs. Not yet confirmed to
be the cause of either — the harness available here runs the page as a hidden
tab, where `requestAnimationFrame` and `ResizeObserver` never fire, and the
whole autosize path is built on both. This fix stands on inspection regardless.
2026-08-07 14:26:54 +03:00
DumiFlex a723243d3d fix(canvas): autocomplete now closes on canvas clicks and hides no-op vars
Two reports against the assembler's editor, both fixed at the source.

The suggestion popover never closed when clicking away. The dismiss listener
was bound to `mousedown` only, and litegraph drives the canvas from pointer
events and calls `preventDefault()` on `pointerdown` to suppress native
text-selection and drag — a prevented `pointerdown` also suppresses the
browser's compatibility `mousedown`, so the listener never heard the click.
On a node graph the canvas is most of the screen, which is why this read as
"it never closes". Now bound to `pointerdown` too, which fires first and
cannot be taken away downstream. `mousedown` stays as the fallback for hosts
with no PointerEvent (jsdom, older webviews); closing twice is idempotent.
Presses inside the popover still don't dismiss, so rows (which commit on
mousedown to beat the host blur) and the scrollbar keep working.

The `$` list also offered variables the assembler cannot render. It runs
`strip_internals` over the render context before resolving, so `$var` naming
a user-flagged internal variable substitutes to nothing — yet the popover
listed 52 matches, most of them `$iteration` and `*_bool` toggles. Those names
legitimately cross the socket for Combine and Derivation to read, so the fix
is not to drop them from the walker but to give the PROMPT surface its own
list: `collectUpstreamRenderableVariables`.

The internal-flag parse existed already as a private copy inside
`widgets/assembler.ts`. Extracted to `internalVarNames` and shared, because
the two surfaces on this same node have to agree about which names the prompt
will render, and they would have drifted with one copy each.
2026-08-07 14:18:40 +03:00
DumiFlex 51bc4893ec fix(canvas): one malformed fixed_values row could break canvas interaction
`resolveChainStatic` iterated `m.entries` with no guard and read
`e.variable_name` with no type check. A library payload that carries
`payload.values` but no `entries` mirror — which is every row installed
straight from the library, since `entries` only appears once a module has been
edited inline — threw "m.entries is not iterable".

The blast radius is the reason this matters. The walker runs inside
`onConnectionsChange` via `extension/reactive.ts`, so the throw does not
degrade to an empty preview: it escapes into litegraph's connection handling,
and one bad module takes out canvas interaction for the whole graph.

The other two `entries` readers in this file already used `?? []`; this one
was the outlier. Found while hand-writing test fixtures for the assembler
editor — hit it twice before recognising it as the code's problem rather than
the fixture's.
2026-08-07 14:10:40 +03:00
DumiFlex bb40882238 feat(canvas): assembler template gets the rich editor, keeping its link input
The template input now renders through RichTextInput, so it has the booru tag
autocomplete, `$var` chips with producer attribution, and the same editing
behaviour as every other WP text surface.

No payload transition. It is the SAME `template` widget: same name, same
position in `widgets_values`, same plain string value — only the renderer
changed. A workflow saved before this loads its template back untouched;
verified by loading a hand-written pre-change workflow JSON.

The seam is `widgetType`. The frontend picks a widget constructor with
`t.widgetType ?? t.type` while the socket keeps `t.type`, so tagging the input
spec routes the lookup to us without touching what the socket accepts. It has
to go through `extra_dict`: V3's `widget_type=` parameter would ALSO change
the socket, because `WidgetInput.get_io_type` returns widget_type when set —
the input would stop being STRING and no upstream STRING output could feed it.
`extra_dict` is merged last in `Input.as_dict` and never consulted by
`get_io_type`. The new schema test asserts both halves together, since either
one alone passes while the feature is broken.

Keeping the link input needs one more thing: ComfyUI only calls
`addInput(name, spec.type, {widget})` when the widget the factory returns has
no `socketless` flag, and `createDomWidgetHost` hardcoded `socketless: true`.
Hence the new `socketed` option — correct for every other WP widget, exactly
wrong for this one. While a link IS connected the editor goes read-only with
a placeholder saying so, matching the stock multiline widget. Derived from the
link rather than the widget's `computedDisabled`, which is recomputed lazily
during a draw and still read `true` a second after the link was removed.

Why a separate file and not `assembler.ts`: the helper widget must stay
socketless (it is an editor, not a value), and the two widgets need opposite
answers to that one question.

The helper's template mutators lose their `inputEl` branches. Those had been
dead for a while — the frontend moved widget values into a store, so the
textarea at `widget.inputEl` is detached and unrendered, and each of those
functions was really working through the `w.value` write sitting next to it.
Insert is the exception: it needs a caret, so it asks the editor through a
WeakMap hook instead of replacing the whole string.

Verified live on frontend 1.48.6 with Vue Nodes on: legacy value restores,
STRING link connects and disconnects, serialize round-trips, clear / remove /
insert all propagate, `$` autocomplete lists upstream vars with attribution,
tag autocomplete and its legend work, and a full Context → assembler chain
previews correctly.
2026-08-07 02:20:18 +03:00
DumiFlex bb91476f9f fix(spa): rich text editor swallowed an external clear, and gains a caret insert
Two changes to RichTextInput, both needed by the assembler's template editor
but neither specific to it.

The echo guard on the `modelValue` watcher compared only the string:

    if (next === lastEmittedValue) return;

`lastEmittedValue` seeds from the value present at mount. Every canvas widget
mounts empty — ComfyUI restores the saved workflow value AFTER the widget is
built — so `lastEmittedValue` stays `""` until the user types, and every
later external `""` looked like the editor's own echo and was dropped. The
assembler's Clear template button is exactly that write: the widget value went
to `""` and the old template stayed on screen. The guard's real question is
"does the DOM already show this?", so ask that instead. Non-empty writes were
never affected, which is why this survived this long.

`insertTextAtCaret` is the component's first public method. The assembler's
chip strip is a SEPARATE widget on the same node, so it cannot splice into
this editor's DOM; it used to splice into the native `<textarea>` at
`widget.inputEl`, which no longer renders. Writing `widget.value` instead
works but replaces the whole string and drops the caret.

It separates on BOTH sides. Leading-only was the old textarea's behaviour and
was already subtly wrong: inserting `$mood` before `portrait` yields
`$moodportrait`, which re-parses as a variable named `moodportrait`, so the
insert quietly changed what it inserted. Caught in the browser, then pinned
with a test that drives a real jsdom Selection — the other cases exercise the
end-of-text fallback, which cannot reach the trailing branch.

Both regression tests were confirmed to fail with their fix reverted.
2026-08-07 02:11:57 +03:00
DumiFlex 3de5b6a924 feat(spa): tag rows carry a tinted pi-tags icon instead of a colour bar
One slot now says both things: the glyph says "booru tag", its tint says which
category. The separate 4px colour bar goes.

`pi-tags` PLURAL is deliberate. `pi-tag` singular is already the fixed_values
module kind in KIND_ICON_MAP, so a booru tag row would have rendered
identically to a library module row. Checked every `pi-*` in the source before
choosing: `pi-sparkles` is the wildcard kind, `pi-sliders-h` the injector's
sampler, `pi-palette`/`pi-hashtag` are taken too. `pi-tags` is unused.

Neutral grey is `general`, which is most rows -- colouring every line would
make the coloured ones stop meaning anything.

Three defects caught by measuring the rendered result rather than reading the
diff: `artist` and `meta` kept `background:` because extra alignment
whitespace defeated the edit, so both fell back to inherited dim; the legend
icon lost its `pi pi-tags` classes and rendered as a coloured but glyph-less
box; and the legend swatch kept `width: 4px; height: 10px` from the bar it
replaced, squashing the icon to a sliver. Verified live: 20/20 row icons at
12px in the primeicons font, and legend glyphs at 11px in character green,
artist orange and general grey.
2026-08-07 01:31:33 +03:00
DumiFlex ca87384b5b feat(fixed-values): rich text input, tags on and $ / @ off
The canvas fixed-values row was a bare <textarea>, so booru tag autocomplete
never reached it. It is a RichTextInput now.

`$` and `@` stay off there, and that is the ENGINE's rule rather than a UI
preference. `fixed_values_handler.py` says the surface is a binding PRODUCER,
not a consumer: a fixed value DEFINES what `$name` resolves to, so `resolve_text`
gates var reads and `@{}` refs off and renders such a token as literal text
with a warning.

The frontend did not encode that. It hardcoded `surface === "wildcard"` in the
`$` gate, which made the SPA's fixed-values editor MORE permissive than the
engine -- offering `$` autocomplete for tokens the engine would never resolve,
while `parseForSurface` already collapsed those same vars to literal text. The
gate now derives from the producer/consumer split the engine defines, so both
surfaces agree and the existing SPA divergence closes with it.

No machinery was lost in the swap: RichTextInput uses the SAME
`useGrowableField` composable ValueRow did, so auto-grow, the overflow fade and
the drag grip come with it, and ValueRow's duplicate copy goes.

A Vue prop-casting trap cost two RichTextInput tests on the way, and the fix is
the interesting part. `allowVars?: boolean` is inferred as a Boolean prop, and
Vue casts an ABSENT Boolean prop to `false` rather than undefined -- so
`props.allowVars ?? <surface default>` never reached the default and EVERY
surface silently lost `$`. It is now `"auto" | "on" | "off"`, which no
prop-casting rule can quietly rewrite. Two existing tests caught it; without
them this ships as "$ autocomplete stopped working everywhere".

ValueRow's tests updated: the "is a TEXTAREA" assertion became "is multiline
and grows", which is the requirement the tag name was standing in for.
2026-08-07 01:22:59 +03:00
DumiFlex 8aea38dd45 feat(injector): rich text input for the template, scoped to this row's refs
The binding modal's template was a bare <textarea>, so it had no `$`
autocomplete at all -- reported as "the $ variable autocomplete does not work
in the context injector". Nothing was broken; the machinery had simply never
been attached to that field.

The suggestion pool was already sitting next to it. `insertOptions` builds the
"+ $ref" menu from this row's own references -- a socket row can reference only
its own slot, the general row can reference every socket -- and the
autocomplete now reads the SAME list, so the menu and the popover cannot drift.
Deliberately not the library's variables: nothing in this modal can resolve
one, and offering them would suggest bindings that silently do nothing.

Surface is `assembler` because this composes a template. Explicitly not
`wildcard`, which suppresses `$` on purpose.

`insertSlotRef` no longer splices at the caret. RichTextInput is
contenteditable with atom spans, so `selectionStart` does not exist and the old
path had nothing to splice into; it appends instead. Honest about what it does,
and the menu is the fallback now that typing `$` opens the same list in place.

Tests updated rather than worked around: seven drove the field as an
HTMLTextAreaElement. The modal's own suite stubs RichTextInput in its mount
helper the way the combine editor's tests do, and the two widget-level tests
drive the component directly, since jsdom does not implement contenteditable
editing and those tests care that the value reaches the persisted JSON rather
than how the caret moved.
2026-08-07 00:51:00 +03:00
DumiFlex e2b9f2f053 feat(spa): refresh and remove buttons for the tag list
Managing the file meant opening a file manager, which is how the maintainer
had to remove it to test the disabled state.

Refresh re-reads the path. The server already notices an mtime change on its
own, but nothing tells the PAGE to go and look, so a file copied in by hand
stayed invisible until a reload. Both actions also drop the cached
availability the editors hold, so an open editor picks up a list that appeared
or disappeared without a reload.

Remove deletes it. Same shape as the download for the same reason: no caller
input reaches the path, and a symlink there is refused rather than followed --
otherwise this unlinks whatever it points at.
2026-08-07 00:17:28 +03:00
DumiFlex a4a89e5694 fix(extension): put the tag setting where it belongs and guard the drift
The setting I added last commit was wrong three ways, all visible in one
screenshot of the panel: filed under "2. Sizing" when it is behaviour, in a
bespoke `editing.` namespace matching none of the three the playground routes,
and absent from the Display Playground entirely.

Now `wildcardPipeline.behavior.tagAutocomplete`, under "7. Runtime behavior",
registered in the playground store's routing set and bound to a control in the
modal.

The real fix is the fourth change. `playground-store.ts` carries a comment
asking the next person to "keep this routing in sync if a new key is added",
and a comment is not a mechanism -- it is a note that gets read after the
divergence, by whoever is looking for the cause. `playground-parity.test.ts`
reads both sources and asserts, for every registered setting, that it uses a
routable namespace, that the store knows its key, and that the modal binds a
control to it. 18 settings, three checks each.

That test caught its own bug on first run: the id regex matched `id:` sites
only, and most entries reference a `SETTING_ID_*` constant, so it found exactly
one setting. The "finds them at all" assertion exists for precisely that and
fired immediately -- a parity test that silently checks nothing is worse than
no parity test, because it reports success.

Playground rows for behaviour settings have nothing to preview on the right.
It is listed anyway: the playground is where users expect the settings
mirrored, and "has no preview" is not a reason to be absent from the list.
2026-08-07 00:09:05 +03:00
DumiFlex 107e1de41e feat(extension): enable tag autocomplete on the canvas, with its own setting
The canvas editors were bundled and rendering-ready but the feature never
fired there. Cause: the setting was read via `useUiStore()` behind a try/catch,
and NO Pinia is installed in the canvas widget mounts at all -- so it threw,
returned false, and stayed silently off. The try/catch I added to fix 113 tests
was hiding a missing implementation rather than handling an edge case.

Each host now answers for itself. `utils/tagSetting.ts` reads ComfyUI's own
setting when `app.extensionManager.setting` exists and the persisted SPA value
otherwise, so neither host depends on the other's machinery. The two switches
are deliberately independent: wanting suggestions in the full manager does not
mean wanting them while squinting at a node on a canvas.

The ComfyUI setting is hand-rendered rather than a plain boolean so it can
DISABLE itself when no tag list is installed, saying where to get one. A
checkbox that switches on and does nothing is the exact failure this feature
keeps having to design around -- a control implying a capability it does not
have.

Scope is unchanged and worth stating: RichTextInput only ever mounts inside
this extension's own widgets, so nothing here touches ComfyUI's text widgets or
another extension's. That is a property of where the component is used, and the
obvious future "improvement" of hooking ComfyUI's generic textarea would throw
it away.

Also fixes arrow navigation. The header and legend are sticky, so they overlay
the scrollport rather than shrinking it, and `scrollIntoView({block:"nearest"})`
only knows about the scrollport -- arrowing to the first or last row parked it
under a pinned band, selected and invisible. `scroll-margin` on the rows is the
mechanism for exactly that. Verified: arrowing to the last row leaves it fully
above the legend, and back to the first leaves it clear of the header.
2026-08-06 23:48:26 +03:00
DumiFlex b8957acf34 fix(spa): blank legend swatch and a row visible under the legend
Two reported by the maintainer from a screenshot, both specific.

The `general` swatch rendered as nothing. Legend swatches carried only a
modifier class, and `--general` was never defined -- the row bars get their
neutral grey from the base `.wp-rt-tag__cat` rule, which the legend markup did
not use. Swatches now have their own base class and `general` is stated
explicitly rather than inherited by accident, since it is a real category and
not an absence.

A scrolling row showed through beneath the legend band. The popover root has
`padding-bottom`, and `bottom: 0` sticks the legend to the CONTENT box, leaving
that padding transparent below it. The legend is now pulled down over it with a
negative offset and matching padding.

Verified live at full scroll: no blank swatches, and no row pixels below the
legend -- the only gap left is the 1px border.
2026-08-06 23:25:07 +03:00
DumiFlex 36969d089a fix(spa): add the missing tag legend and pin it while the list scrolls
The legend was designed, described and never built -- rows shipped with a
colour bar and nothing saying what the colours meant.

It appears only when more than one category is in the CURRENT results. On an
all-general query every bar is the same neutral grey, and a legend naming four
colours none of which are on screen is a row of height explaining nothing.
Verified live: a `mik` query renders "character artist general" -- the three
kinds actually present, not the full five.

Also pins the header and the legend. `overflow-y: auto` sits on the popover
root, so everything inside scrolled together: the query you are refining
scrolled out of view as you moved down the list. That was already true of the
`$`/`@` popover and is fixed for both. Sticky rather than a flex wrapper
because the row list has no container element -- restructuring the existing
markup would have put the working sigil popover at risk for no gain. Negative
margins cancel the root's horizontal padding so the pinned bands span the full
width instead of leaving a gutter for rows to show through.

Verified in the running SPA: scrolling the list 691px leaves both the header
and the legend visible while the rows move, and a field 189px from the bottom
of the viewport flips the popover above the caret rather than overflowing the
screen, legend included.
2026-08-06 22:18:23 +03:00
DumiFlex 51db05620d feat(spa): wire the booru tag autocomplete into the editor
Completes #27. Typing a word in an option value now suggests tags; `$` and `@`
are untouched.

Non-interference is structural, not coordinated. The tag mode is a third value
on the SAME `acTrigger` state, so only one popover can exist -- two independent
popovers could both open over one caret, which is the behaviour this feature
was not allowed to introduce. And it is probed only where `probeAutocomplete`
already declined the caret, so the sigil probe gets first refusal on every
keystroke. `probeTagWord` re-checks for a preceding sigil as a second lock, and
19 tests assert the two probes never both claim a caret.

Commit inserts PLAIN TEXT. `$`/`@` build chips because they carry engine
meaning; a tag is just characters in a prompt. Deliberately not a branch inside
`insertChipAtCaret` -- its defensive re-derivation scans backwards for a
`$`/`@` run, which is exactly wrong for a sigil-less word.

Rows follow the design pass: the line that gets INSERTED is always primary, so
an alias hit reads `head_tuft` with `from blue_head_tuft` beneath rather than
leading with the alias, which is not what Enter produces. The category bar
appears only when the loaded file has categories, and the legend only when the
results actually differ -- a column of identical grey bars explains nothing.

Three bugs found by QA against the real SPA, none of which any test caught:

- `useUiStore()` at setup scope threw in 113 existing tests, which mount this
  component without Pinia. Every other setting arrives as a prop, so the store
  dependency was the odd one out as well as the broken one; it is now resolved
  lazily and degrades to "off" when Pinia is absent.
- The popover renders on `acOpen && acItems.length > 0`, and tag mode returns
  no `acItems` by design, so it could never appear. Now gated on the row count
  for whichever mode is active.
- The "shared" status promise was a `let` inside `<script setup>`, which
  compiles into `setup()` and is therefore per-instance: 135 identical requests
  on a 133-option wildcard. Moved to a real module -- measured 135 -> 1.

Verified in the running SPA: the popover opens with 20 real rows from a
286,337-entry list, Enter completes `blue_h` to `blue_hair` as plain text with
zero chips, and typing `$mood` shows no tag rows at all.
2026-08-06 22:00:21 +03:00
DumiFlex b2fedcd87a feat(spa): settings entry for the tag autocomplete
Client half of #27, minus the editor wiring. Adds the API client namespace,
the types, the persisted on/off setting, and the Settings card.

The card separates two facts that look like one: whether the user WANTS
suggestions, and whether a tag list exists to make them from. The toggle owns
the first, the status row owns the second. Conflating them produces a switch
that silently does nothing, which is why /wp/api/tags/status reports
`available` independently of the setting.

Off by default -- the feature needs a list the user has to fetch first, so
defaulting it on would advertise a capability that is not there.

Four states the card renders honestly: nothing installed, installed and on,
downloading, and a two-column file that works but cannot colour-code. The last
reports "no categories" rather than pretending, because the dropdown will
visibly differ.

The path is shown even when nothing is installed -- it is the answer to "where
do I put my own?", which is the supported alternative to downloading ours.

The card states in the UI that Download fetches from GitHub, and that it is
the only thing this extension fetches. The README says so too, but someone
deciding whether to press a button should not have to read the README first.

Follows the Updates card shape (Field + Toggle, then a status row with an
action) because it is the same interaction: tell me the state, give me the
verb. Designed against the real tokens with real rows from a 286k index, not
mock data.
2026-08-06 21:35:52 +03:00
DumiFlex e7feeaa112 feat(api): download the tag list, with the capability removed rather than validated
The only outbound request this extension makes, and only on an explicit click
in Settings. Documented in the README under "Network access" -- a user should
be able to find out that a plugin talks to the internet without reading its
source.

ComfyUI serves its API unauthenticated, so "fetch a URL and write it to disk"
is the most abusable endpoint shape available. Each capability is therefore
removed rather than checked at the door:

- The URL is a module constant. The request body is ignored entirely, so there
  is no parameter to smuggle `http://169.254.169.254/` or an internal host
  through.
- Redirects are validated per hop against a fixed host list. GitHub serves
  release assets via a CDN so the chain must leave github.com -- to those hosts
  and nowhere else, https only.
- The destination is computed from ComfyUI's user dir plus a fixed filename, so
  no caller input reaches the path. A symlink there is refused rather than
  followed, so the write cannot be redirected.
- The body is size-capped while streaming. Content-Length is checked when
  present but never trusted as the only guard, since it is whatever the server
  chose to say.
- A single-flight lock means repeat triggering gets "already running" instead
  of N concurrent multi-megabyte fetches.
- The payload is parsed BEFORE it replaces anything, then moved atomically. A
  response that is not a readable tag list leaves the existing one intact, and
  an interrupted download cannot leave a half-written file in place.
- No proxy or cookie handler on the opener: one public asset, no ambient
  credentials.

21 tests, one per removed capability, because a comment claiming a capability
is absent is worth nothing on its own. Includes the metadata-address and
suffix-confusion hosts (`github.com.evil.example.com`), a Content-Length that
lies about a body over the cap, and a symlinked destination.

What is deliberately not claimed: this does not defend against a compromise of
our own release. It is a tag list, parsed into strings and never executed, so
the worst a bad file achieves is bad suggestions.
2026-08-06 19:00:07 +03:00
DumiFlex 33c2e718c1 ci: build the booru tag list on demand, publish to a fixed URL
Data source for the optional value autocomplete (#27). Replaces the
community script this started from, which walked all 1000 allowed pages and
kept only `name,post_count`.

Three changes, each from something measured against the live API on
2026-08-06.

A post-count floor instead of a page count. `search[order]=count` sorts
strictly descending ACROSS pages:

    page    1: first 8,244,950   last 55,918
    page  100: first        68   last     67
    page  800: first         1   last      1

so a popular tag cannot hide on a later page and stopping early provably
loses nothing. Walking to page 1000 spends ~900 requests and a quarter of an
hour collecting single-post tags. The default floor of 20 lands near what
other extensions ship.

Aliases, from the separate `tag_aliases.json` endpoint. `tags.json` carries
none, and aliases are what let someone type `hires` and land on `highres` --
demonstrably the most valuable field after the names.

Category is kept. The API returns it and the old script discarded it; it is
what lets the UI tell an artist tag from a character tag.

Also retries with backoff rather than aborting on the first non-200, which
over a 100-request run means one blip no longer discards everything.

The User-Agent is pinned to bare `product/version` and must stay that way --
Danbooru's bot filter 403s a bare token, a longer description, or anything
containing a URL. Adding a contact URL is the usual courtesy for a scraper and
is exactly what breaks it, so the reasoning is recorded next to the constant.

MANUAL ONLY. The list describes Danbooru, not this project, and goes stale
slowly: a two-year-old file still autocompletes correctly because staleness
moves post counts and therefore ranking, while tag names barely change. That
is not an argument for a timer against someone else's API. Output goes to a
fixed non-semver `tag-list` tag, replaced in place, so the download URL is
stable and the SPA can hardcode it -- and so semantic-release ignores it.
2026-08-06 18:33:02 +03:00
DumiFlex b4d764fd4e feat(api): booru tag index behind /wp/api/tags/*
Server half of the optional value autocomplete (#27). No UI yet.

The tag list is a file the user supplies at one path we own, under ComfyUI's
user dir -- not the package folder, which a reinstall replaces and the asset
pruner walks. Reading other extensions' tag files was considered and rejected:
it couples us to layouts they are free to change. Our own CI workflow will
generate the list instead.

Bundling was never viable regardless -- a usable list is 2-6 MB against a
420 KB gzipped budget -- and the file is not sent to the browser either.
Prefix search runs here and the client gets ~20 rows per keystroke, which is
the drift-poll lesson applied before making the mistake rather than after.

Both shapes in the wild are accepted:

    name,post_count                       ComfyUI-Custom-Scripts
    name,category,post_count,"aliases"    Danbooru API export

with no header to switch on, so they are told apart by magnitude: a category
is a small enum, a post count is not.

Built on the FIRST SUGGEST REQUEST, never at plugin import. Benchmarked on a
real 286,337-entry file: 785 ms to load, 5-950 us to query. Paying that at
startup would slow every ComfyUI launch for a feature most users leave off --
the exact problem this cycle just finished fixing.

Two defects the benchmark caught, both alias-related:

- `1girl` matched both itself and the alias `1girls`, which resolves back to
  `1girl`, so the same row rendered twice. Results are now deduplicated by the
  tag that would actually be inserted, and a direct hit beats an alias hit for
  the same canonical.
- An alias match displayed only its canonical, so `ka` returned `pokemon` with
  nothing to explain it. The response now carries `matched` alongside `name`
  so the UI can render `kalos -> pokemon`.

Prefix-only by design. Substring matching over 200k tags buries the wanted tag,
and every booru autocomplete users already know behaves this way.

Also exposes `comfyui_user_dir()` from engine.db.connection -- the detector
chain already existed there but only as private helpers behind the DB path,
and reaching across modules for underscore names to find it was the wrong
shape.
2026-08-06 18:23:27 +03:00
DumiFlex fd9ba60bec perf(packaging): stop leaking every previous build into the install
Investigating slow ComfyUI page loads on a real install turned up this
project as the single largest contributor, by a wide margin.

    js/    518 files   36.0 MB    179 stale chunk copies
    web/  1256 files   22.0 MB   1120 stale chunk copies

ComfyUI fetches EVERY *.js under WEB_DIRECTORY at page load, so that install
requested 237 of our chunks where 58 were live: 9.9 MB, 42% of all extension
bytes on the page and 27% of all extension requests. Twelve copies of `about`,
nine of `ContextWidget`.

Two independent causes, both ours.

Chunks are content-hashed, so every release emits new filenames, and Manager
installs an update by extracting over the installed folder without deleting
what the new version dropped. Each mechanism is reasonable alone. Together
they never remove anything. It stayed invisible for a dozen releases because
`emptyOutDir` wipes the output directory on every local build -- only installs
that UPDATE accumulate, and a maintainer never sees one.

And `sourcemap: "hidden"` still WRITES every .map, omitting only the
`sourceMappingURL` comment, so 22.5 MB of maps shipped that no browser had
ever requested. `pack-release.mjs` excluded them from the GitHub zip, but the
registry package comes from pyproject's `includes = ["js", "web"]`, which has
no exclusion -- so the fix has to be not emitting them.

Now: each build writes `.wp-assets.json` listing what it produced, and the
plugin prunes anything hashed and unlisted at import, before ComfyUI scans
WEB_DIRECTORY. Self-healing -- existing installs clean themselves on the next
start with no user action.

Deliberately conservative, because deleting a live file breaks an install
while missing a stale one only costs disk: no manifest, an empty manifest or a
corrupt one all mean do nothing; only bundler-shaped names are candidates
(hashed chunks, plus any .map -- the entry map is unhashed, which the hash
pattern alone walked straight past); every failure is swallowed.

Verified against a copy of the real install, updated the way Manager would:
1,881 files -> 302, 60 MB -> 10.9 MB, 1,579 removed. All 551 relative module
imports across the surviving chunks still resolve, and every asset
`web/index.html` references is intact.
2026-08-06 17:58:01 +03:00
DumiFlex 0d01a633d0 fix(editors): show the new filter bars on real-world list sizes
The constraint and derivation filters inherited the fixed-values threshold of
"more than 8 rows" unexamined, and it hid the feature outright. Measured
against a real library: 53 derivations with at most 3 rules, 21 constraints
with at most 2 exceptions. Nobody would ever have seen either filter.

Rules now appear above 2, exceptions above 5. Not merely to make them visible
-- a derivation rule is a whole card rather than a table row, so three of them
already scroll, while a fixed-value row is short enough that eight fit.

The prop's doc comment now says thresholds are the caller's call, sized from
how tall that surface's rows are, so the next surface does not inherit 8 by
accident too.
2026-08-06 17:29:11 +03:00
DumiFlex 39266cb87b fix(constraints): reject two exception rules for the same pair
Reported by the maintainer: the editor accepted the same source/target
combination more than once with contradictory modes and factors. Only one ever
applied. `combine_constraint_factor` files exceptions into a dict keyed by the
pair, so the last one in the list wins and every earlier one is discarded.

Verified against the pure math, and the alarming direction is real:

    [boost x3, reduce x0.5] -> 0.5        not the combined 1.5
    [reduce x0.5, boost x3] -> 3.0        order decides the result
    [boost x3, exclude]     -> EXCLUDE
    [exclude, boost x3]     -> 3.0        the exclusion is thrown away

That last line contradicts the documented model in CLAUDE.md, which says
constraints combine "with `exclude` (factor 0) absorbing". Within duplicate
exceptions it neither combines nor absorbs, and the deciding factor is list
position -- which no surface displays and nothing lets you edit.

Enforced at the AUTHORING boundary, not in `validate_payload`. `resolve` calls
that on every graph run, so a rule there would turn an already-stored payload
into a hard failure mid-execution -- strictly worse than the silent
misbehaviour it replaces, and unreachable for a user who cannot open the editor
without the graph running. Handlers may now expose an optional
`validate_authoring`, called by the module create/update paths and by bundle
child validation; existing rows stay executable.

Engine math is deliberately unchanged. Making duplicates combine would have
been defensible -- it is what the docs describe -- but it silently alters
re-weighting for anyone who already has them, and "you cannot create this
anymore" achieves the same end without touching anyone's output.

Scanned the maintainer's library first: 21 constraints, 0 duplicate pairs, so
nothing existing is affected either way.
2026-08-06 17:27:30 +03:00
DumiFlex ff6332cf23 fix(api): stop stamping our header on ComfyUI's own responses
`_startup_id_middleware` is registered on ComfyUI's application, not on a
sub-app, and had no path check despite a docstring claiming it tagged
"/wp/api/*". It therefore stamped X-WP-Startup-Id on every response the whole
server sent: ComfyUI's index, /api/extensions, image outputs, other
extensions' routes. Found by a remote probe during the 2026-08-06 session --
`GET /` came back carrying it.

Nothing depended on the wider scope. The only consumer is
manager/api/client.ts, which talks exclusively to /wp/api/*.

Same principle CLAUDE.md's extension-isolation section applies to CSS
selectors and litegraph node properties, extended to response headers: being a
guest in someone else's application means not writing on their responses.
2026-08-06 06:44:16 +03:00
DumiFlex 49f882f131 perf(api): make the drift poll conditional and stop rebuilding it
`drift-store.ts` polls /wp/api/modules/hashes and /wp/api/bundles/hashes every
5 seconds. Both the SPA and the in-graph WP_Context subscribe, so a ComfyUI tab
with a Context node on the graph polls these whether or not the manager is ever
opened.

Measured against a live 2.13.1 install on 2026-08-06: 33,008 bytes per poll, no
ETag, no Cache-Control, no Last-Modified, and 0 of 39 observed requests served
from cache. That is ~24 MB/hour per open tab on an idle page. Invisible on
localhost; over Tailscale it was the largest thing this extension does.

Producing it was not cheap either, despite the docstring's "lightweight ...
without measurable cost" -- that described the response, not the work.
`ModuleRepository.list()` is `SELECT *` plus a json.loads of every payload in
the library, and the handler kept three fields per row and discarded the rest.

Two changes:

- Conditional responses. `json_ok_revalidated` emits an ETag plus
  `Cache-Control: no-cache` -- store it, but revalidate -- and returns a bodied
  304 when `If-None-Match` matches. Nothing changes for the client: the browser
  serves the cached body as a 200, so `fetch().then(r => r.json())` is
  untouched and the saving is entirely on the wire.

- A cheap stamp behind the ETag. `library_stamp()` is
  COUNT/SUM(version)/MAX(updated_at) -- one aggregate row, no payloads. The
  expensive map is built only when the stamp moves, which is the whole point:
  computing an ETag from the response body would have done all the work anyway
  and then thrown it away.

Also narrows the map builders. `list_hash_rows` selects the columns the drift
map needs instead of every column via `_row_to_module`/`_row_to_bundle`, which
parse `tags` (and `children`, for bundles) per row for a map that wants
neither. Bundles get the bigger win -- `payload_hash` is a stored column there,
so that path now parses nothing at all. Modules still parse `payload` because
their hash is computed rather than stored; removing that needs a schema
migration, and the stamp makes it moot in the steady state.

Stamp precision, both directions verified: `set_community_origin` writes
without a version bump and cannot change the map, so the ETag correctly holds.
A favourite toggle goes through `update()`, whose single statement always does
`version = version + 1`, so it does move the ETag and costs one refetch of an
identical map. Accepted rather than fixed -- a rare user action against a second
counter tracking payload-affecting columns only -- and pinned by a test that
says so, and says a future failure would be an improvement.
2026-08-06 04:12:44 +03:00
DumiFlex eb9167e1c1 fix(editors): scope select-all to the filtered rows in the wildcard editor
Same bug as fa9cde05 fixed in the three simpler editors, carried
independently here because this editor predates `useBulkSelection` and was
never migrated onto it. `selectableOptions` was the full option list while the
table rendered `visibleOptionRows`, so select-all reached rows the filter was
hiding.

Worse blast radius than the fixed-values case. Bulk actions here are delete,
set-weight, and tag add/remove. Delete at least changes the row count;
`setWeightSelected` rewrote every weight in the list with no visible signal at
all, and a hand-tuned distribution has no undo.

`toggleSelectAll` now adds or removes only the visible ids, matching the
composable's semantics: a selection survives the user retyping the filter, and
`selectedCount` still reports the honest total.

Also migrates FixedEditor onto ui/ListFilter (the follow-up fa9cde05 deferred)
and drops the ~50 lines of markup and CSS it duplicated. `test-prefix="fv"`
keeps its data-test hooks byte-identical, so its tests are unchanged.

Two layout fixes found by QA, not by tests:

- ListFilter had no flex growth, so it never claimed the card header's slack
  the way the fixed-values filter it was extracted from did. Ported the
  `flex: 1000 1 auto` and its rationale.
- `.wp-card__header` could not wrap, so a crowded header shaved its flexible
  child instead. The derivation card has four buttons and squeezed the filter
  input to 18px in a narrow window. The header now wraps and the filter has a
  220px floor; measured 18px -> 194px, with every other card header on the
  page unchanged at 42-50px and nothing overflowing.

Verified on a 133-option wildcard: filtering to 20 and selecting all reports
20, not 133; clearing the filter keeps those 20; selecting all under a second
filter adds 15 more; deselecting under that filter returns to 20 with the
first selection intact.
2026-08-06 02:48:21 +03:00
DumiFlex fa9cde0546 feat(editors): filter bars on constraint exceptions and derivation rules
The wildcard and fixed-values editors grew a filter; these two never did,
which had it backwards. A constraint's exception list is routinely longer
than the wildcard it filters, and a rule card is tall enough that a dozen
rules is already a scroll.

Extracted `ui/ListFilter.vue` rather than copy the fixed-values markup a
third time, and `utils/listFilter.ts` for the two haystack builders, since
"what does this row match on?" is the part worth testing and mounting an
editor to assert it would test the wiring instead.

Both surfaces match on what the row READS AS, not what it stores. An
exception matches its resolved source/target labels -- the stored values are
frequently `@{uuid}` tokens and nobody searches for a uuid they cannot see.
A rule matches every variable, operator, mode and literal across its branches
and its else, but never its generated id.

Rows carry their ORIGINAL index through the filter. Both editors address rows
by position (`removeRule(idx)`, `exceptions.value[idx]`), so a plain filtered
array would edit and delete the wrong row, and the rule cards would renumber
under a filter and claim Rule 5 is Rule 1.

Also fixes a pre-existing destructive bug in the fixed-values editor, found
while porting the pattern: `useBulkSelection` scoped select-all to every row,
not the visible ones. Filtering 40 values to 3 and hitting the header checkbox
selected all 40, and the delete that usually follows removed 37 rows the user
never saw. It takes an optional second getter for the visible ids; toggleAll
and the header state now read that, while count and selectedIds stay global so
a selection survives the user retyping the filter.

Verified against a running ComfyUI on temporary modules, since nothing in the
library was long enough to trigger either filter: filtering 8 rules to 1 keeps
its card labelled Rule 5, select-all under that filter reports 1 selected
rather than 8, and deleting a filtered exception removes that row -- confirmed
by the surviving rows' mode sequence, not by row text. The temporary modules
were deleted afterwards.
2026-08-06 02:23:59 +03:00
DumiFlex c3e74e206d perf(extension): stop bundling the webfonts into JavaScript
Vite library mode ignores `build.assetsInlineLimit` and inlines every asset
unconditionally, so both woff2 files were base64'd into a chunk: 88,660 bytes
of font became a 118,896-byte JS file, awaited in main.ts's top-level
`Promise.all` and therefore parsed as JavaScript before ComfyUI could finish
registering the nodes.

fonts.ts believed the opposite -- its comment claimed `new URL(path,
import.meta.url)` kept the woff2 as separate assets. That transform is exactly
what Vite intercepts in lib mode, so the intent never held.

The fonts now leave the module graph entirely. `src/assets/fonts/wp-fonts.css`
is a real source file copied verbatim to `js/assets/fonts/` by copy-fonts.mjs;
fonts.ts <link>s it at a URL resolved against its own chunk, and the browser
resolves each woff2 relative to the stylesheet. No path arithmetic in JS, and
a renamed install directory still works.

The href is assembled from array parts on purpose: Vite's asset transform fires
on `new URL(<string literal>, import.meta.url)`, so a literal here would
silently re-inline the stylesheet with no build error and no failing test.

Measured with the gate's own method: 492,407 -> 403,055 bytes gzipped. Total
bytes on a cold cache are about unchanged -- gzip recovers base64's overhead --
so the win is latency and caching, not payload: registration no longer waits,
90 KB left the JS parse path, and the fonts stop being re-downloaded on every
release when the enclosing chunk's hash changes.

TOTAL_LIMIT tightened 490 -> 420 KB. Re-inlining costs ~90 KB, which fits under
490 KB and would have regressed unnoticed; it does not fit under 420 KB.

Verified against a running ComfyUI: the stylesheet and both woff2 serve 200 and
are fetched as fonts, and document.fonts.check() passes for JetBrains Mono,
which only this stylesheet provides.
2026-08-06 02:01:40 +03:00
semantic-release-bot 494b53192d chore(release): 2.13.1 [skip ci] 2026-08-04 19:11:29 +00:00
Dumitru Picalau 8c0f3e418e Merge pull request #17 from DumiFlex/fix/republish-2131
fix(release): re-cut 2.13.0 as 2.13.1 for the registry
2026-08-04 22:10:43 +03:00
DumiFlex d2f548d0e2 fix(release): re-cut 2.13.0 as 2.13.1 for the registry
The registry publish for 2.13.0 failed:

    400 {"message":"The node version already exists"}

The empty 2.13.0 cut earlier today was deleted from the registry, but
deleting frees the LISTING, not the number — version records are
immutable there, the same model as PyPI and npm, and the publisher
requires each new version to be greater than every existing one. So
2.13.0 can never be published again, whatever it now contains.

The consequence is worse than an untidy version history: 2.13.0 carries
the `pending_move` containment fix, it reached GitHub, and it did NOT
reach anyone installing through ComfyUI Manager. Those users are still on
2.12.0 with the unauthenticated file copy/move reachable.

Restores the 2.13.0 notes so this release ships with them rather than the
empty body an emptied `next.md` would produce, plus a line for people
already on 2.13.0 saying the two are the same build.
2026-08-04 22:04:40 +03:00
semantic-release-bot 7e36bb02c6 chore(release): 2.13.0 [skip ci] 2026-08-04 18:54:27 +00:00
Dumitru Picalau 3a5e46cd9d Merge pull request #16 from DumiFlex/fix/db-move-containment
fix(api): confine the deferred DB move to known database locations
2026-08-04 21:53:48 +03:00
DumiFlex 757116dc54 feat(spa): show skipped releases on the What's new page
The page rendered only the newest release, so updating across two
versions meant the middle one's notes were unreachable. That is not
hypothetical: 2.12.0 — the largest release in months — was current for
about an hour before 2.13.0 replaced it, so anyone updating from 2.11.0
would never have seen it.

The last three releases now appear beneath the current one, collapsed,
each with its own notes and a link to its release page. Three because the
gap is small but real; more and the page becomes a changelog, which the
compare link already does better.

Fetched through the same throttle and rate-limit lockout as the update
check — a second GitHub call on a shared 60/hr anonymous quota, on a page
someone may open repeatedly, is exactly what the caching work existed to
prevent. Cached under its own key with the same TTL, and skipped
entirely while a lockout is in force.

Also fixes the "All releases" button, which preferred the current
release's own URL and so went to a single release whenever the release
check had resolved — the common case. It now goes to the releases index,
which is what it says.
2026-08-04 21:47:37 +03:00
DumiFlex 05334d3e05 fix(api): confine the deferred DB move to known database locations
`PUT /wp/api/database/config` accepted `pending_move.from` and `.to` as
any absolute paths. `execute_pending_move` then runs `shutil.copy2` or
`shutil.move` on them at plugin load, as whoever runs ComfyUI. Every
ComfyUI route is unauthenticated by default, so anyone able to reach the
port could schedule:

  * an arbitrary file read — copy a target into `web/`, which this plugin
    serves at `/wp/<path>`, turning our own static server into the
    exfiltration channel;
  * an arbitrary overwrite or deletion — move any file anywhere.

Fixed with an allowlist rather than a sanitiser. The database can
legitimately live in exactly three server-computed locations under one
fixed filename, so there is no reason to accept a client-supplied path.
The live path is also allowed, since WP_DB_PATH / COMFYUI_USER_DIR can
place the file elsewhere and those users must still be able to migrate
off it.

Both paths are `resolve()`d before the membership test, so neither `..`
nor a symlink planted at an allowed location can smuggle a different
target past it, and the resolved values are what get stored — what
executes is what was validated.

`spa.py` already did this correctly (`resolve()` + `relative_to()` + 403);
this route simply missed the pattern.

Adds 7 containment tests covering arbitrary source, arbitrary
destination, the exfiltration-into-`web/` chain, traversal, and a no-op
move — plus one asserting a legitimate relocation still works, since a
guard that breaks the feature it protects is its own bug.
2026-08-04 21:36:59 +03:00
Dumitru Picalau 2d68c61f4a Merge pull request #15 from DumiFlex/ci/no-release-for-tooling
ci: stop tooling-only pushes from cutting a release
2026-08-04 20:24:58 +03:00
DumiFlex c6d8834a42 ci: stop tooling-only pushes from cutting a release
2.13.0 shipped as an empty release. Three commits touching only
`scripts/` and `.github/` were typed `feat(announce)` and
`fix(announce)`; both are release-triggering, so semantic-release did
exactly what it was told, and every user on 2.12.0 was offered an update
with a blank changelog.

The discipline fix — remember to type tooling commits `ci:` — had already
failed once within an hour of the rule being written down, so this is the
structural one. `paths-ignore` means those paths cannot cut a release
whatever the commit type says.

Neither path is part of what a user installs: `scripts/` is dev tooling,
`.github/` is CI. GitHub skips the run only when EVERY changed file
matches, so a push touching shipped code alongside a workflow still
releases normally.

`docs/**` is deliberately excluded from the list: `docs(readme)` is
configured to bump a patch, and `docs/release-notes/next.md` IS the
release body.

publish.yml needs no change — it fires on a published release, so no
release means no registry publish.
2026-08-04 20:19:00 +03:00
DumiFlex 99d3b21334 chore(release): revert the empty 2.13.0 [skip ci]
2.13.0 shipped nothing. It was cut because `feat(announce)` and
`fix(announce)` are release-triggering types, but those commits only
touched `scripts/` and `.github/` — dev tooling that is not part of what
users install. They should have been `ci:`, which is `release: false`.

The result was a version bump with an empty body: every user on 2.12.0
would have seen an update prompt with a blank changelog.

Release and tag are deleted; this restores package.json, pyproject.toml
and CHANGELOG.md to 2.12.0.

`[skip ci]` is load-bearing. Without it this push runs release.yml, which
would analyse the same commits against the now-latest tag v2.12.0 and cut
2.13.0 straight back.

Note that 2.13.0 will be used again by the next REAL release: the
`feat(announce)` commits stay in history and still count toward the bump.
That is the intended outcome — the number is reused for a release that
actually contains something, rather than being burned on an empty one.
2026-08-04 20:16:03 +03:00
semantic-release-bot e9a9d51ea3 chore(release): 2.13.0 [skip ci] 2026-08-04 17:12:12 +00:00
Dumitru Picalau 0572eec472 Merge pull request #14 from DumiFlex/feat/announce-identity
feat(announce): identity, and two channels — staff automatically, public by hand
2026-08-04 20:11:23 +03:00
DumiFlex c7f4d91a10 fix(announce): never emit a half-formed role mention
Unset and empty were already safe. A MALFORMED id was not: any truthy
string went straight into `<@&…>`, so a stray space in the secret produced
a literal `<@&   >` and a pasted mention produced `<@&<@&123>>` — visible
junk at the top of the most public message the project sends.

`normalizeRoleId` is now the single decision point, applied inside
`buildPayload` rather than at the call site so every caller gets the same
guarantee. It trims, unwraps a pasted `<@&123>`, and requires a 17-20
digit snowflake. Anything else is rejected rather than guessed at: a ping
that silently does not happen is a far smaller problem than a broken one
that does.

`allowed_mentions.roles` is derived from the same normalised value, so
the two can never disagree — a content string naming a role the payload
does not permit would be a mention that renders but never notifies.

The CLI warns when a non-empty id was supplied and rejected. Dropping it
silently would leave someone wondering why the ping never fired.
2026-08-04 20:05:45 +03:00
DumiFlex 45a9092bbc feat(announce): two channels — staff automatically, public by hand
One webhook meant the choice was all-or-nothing: either every release
announced itself to everyone, or nothing was automated. Two channels,
deliberately asymmetric:

  staff   posts AUTOMATICALLY on every release
  public  ONLY from a deliberate manual dispatch

The staff copy carries a link back to this workflow with the inputs
spelled out. That is what makes it a review step rather than a duplicate
feed: the post being read IS the post that will go out, and the button to
send it is attached to it. Otherwise "now go publish it" is a separate
instruction living in someone's head.

The public copy does not get that field — an announcement should not tell
its readers how to re-send it. Returned as `undefined` rather than an
empty array, since an empty field block renders as dead space.

Guarded twice. The workflow only passes `--channel public` on a manual
dispatch, AND the script refuses public when `GITHUB_EVENT_NAME=release`.
One of those is enough; both are cheap, and the failure mode is
announcing to everyone with nobody having read it.

Only the public post pings the role. A ping in the staff channel on every
release is noise in a room that exists to watch releases.

`DISCORD_WEBHOOK_STAFF` / `DISCORD_WEBHOOK_PUBLIC`, with the existing
`DISCORD_WEBHOOK_URL` kept as a staff fallback so the current
single-webhook setup keeps working untouched.
2026-08-04 20:01:25 +03:00
DumiFlex 2dfed26c1e ci(announce): post as Wildcard Pipeline, not as the webhook's own name
v2.12.0 went out as "Captain Hook" with a placeholder avatar — a webhook
posts under whatever name and image it was created with, and Discord's
default is a generic pair.

`username` and `avatar_url` override that per message, so the identity
lives in the payload rather than in a webhook's settings. It then stays
right if the webhook is ever rotated or recreated, and it is the same in
every channel the automation posts to.

Avatar is the 512px manifest icon, fetched from raw.githubusercontent by
Discord server-side. It has to be raster — an SVG will not render, which
rules out the repo's favicon.svg, and that failure mode is silent. Pinned
to `main` rather than a tag so replacing the logo needs no code change.

`DISCORD_USERNAME` / `DISCORD_AVATAR_URL` override both. An empty value
falls through to the defaults rather than blanking the identity and
handing the post back to the webhook's name.

The dry run now prints both, since identity is invisible in the embed
body and is exactly what was wrong the first time.
2026-08-04 19:52:51 +03:00
Dumitru Picalau 5585583765 Merge pull request #13 from DumiFlex/ci/discord-announce-node-setup
ci: stop the announce job probing for a pnpm cache it never uses
2026-08-04 19:45:41 +03:00
DumiFlex c4a761d071 ci: stop the announce job probing for a pnpm cache it never uses
The v2.12.0 announcement failed before it ran a line of script:

  Unable to locate executable file: pnpm

`actions/setup-node` sniffs the lockfile, sees pnpm, and tries to prime
its cache — but this job never installs pnpm. It does not need to: the
announce script has no dependencies at all, just plain ESM and the global
fetch built into Node 18+. Nothing is installed, so there is nothing to
cache.

Caught only in the real run because the failure is in the runner's
toolchain setup, not in anything a local invocation of the script
exercises.
2026-08-04 19:39:44 +03:00
semantic-release-bot 736e152167 chore(release): 2.12.0 [skip ci] 2026-08-04 16:37:01 +00:00
Dumitru Picalau a2756f6c0a Merge pull request #12 from DumiFlex/feat/qol-sweep-v2.12
feat: Nodes 2.0 support, value provenance, and an editable-at-scale library
2026-08-04 19:36:11 +03:00
DumiFlex 15c3474519 perf(seeds): stop FrameChips inlining a third copy of the theme
`FrameChips.vue` imported `theme.css` into its scoped style. The sheet
declares its tokens on `:root`, so one copy anywhere in the document
serves everything — and this component only ever renders inside
ContextLoopWidget or SeedListWidget, both of which already import it.

Being a shared component, its chunk then carried the whole 521-line sheet
a third time. Removed, with literal fallbacks added to the two
declarations that lacked them so the component still renders if it is
ever used somewhere unthemed.

The gzip win is small (~0.3 KB) because the chunk it lands in already
contained the sheet via SeedListModal, and gzip dedupes the repetition
well. Worth doing anyway: the duplication would start costing real bytes
the moment these modules split into different chunks.

Note for anyone reading the bundle report on this PR: `FrameChips.js` is
listed as a new 10.70 KB chunk and `SeedListModal.js` as removed at
8.33 KB. That is one chunk being RENAMED, not 10.70 KB of new code —
Rollup names a shared chunk after one of its modules, and adding
FrameChips to the chunk that already held SeedListModal and SeedLockRow
changed which name it picked. Real new weight is closer to 2 KB.
2026-08-04 19:29:14 +03:00
DumiFlex 87382e650e Merge origin/main into feat/qol-sweep-v2.12
Brings in the 2.11.0 release commit. Only conflict was
docs/release-notes/next.md: main cleared it when 2.11.0 shipped, this
branch holds the unreleased v2.12 notes. Kept ours — clearing it would
ship a release with an empty body.
2026-08-04 19:15:34 +03:00
DumiFlex f240aeaf05 docs(release): bring the notes up to date, and declare Nodes 2.0 in the README
Ten commits landed after `next.md` was written and none of them were in it.
Anything left out of that file simply does not ship in the notes.

No new highlights. The cap is 4-5 and it was already at 5 — per the file's
own rule, if a sixth feels essential something else was not a highlight.
Per-frame lock and bypass is a shortcut to something the seeds dialog
already did, so it goes in the tail rather than displacing anything.

Tail gains six entries in two new groups: the frame chips and collapsible
grid on the Loop and Seed List, the broken-reference fix, the what's-new
page, and the update check that was burning GitHub's hourly allowance.
Count corrected 20 → 26, which the update dialog computes independently
from the same bullets.

Also fixes a real bug in the announcement transform, caught by previewing
it against this file: `splitBody` stripped `<details>` BEFORE counting the
tail. The "Also in this release" bullets live inside that block, so it
counted nothing real — while the authoring guidance in the trailing HTML
comment contributed bullets of its own. A 26-change release announced
itself as 3. Comments are stripped, details are not.

README gets the Nodes 2.0 line. Renderer support is a compatibility fact
people check before installing, it was the single largest piece of work in
this release, and the README did not mention it anywhere. The "switching
between them with a workflow already open" clause is deliberate: that is
what repeatedly broke and is now specifically handled.
2026-08-04 19:13:59 +03:00
DumiFlex 911e9839a4 feat(release): derive the Discord announcement from the release notes
Announcements were written by hand, which is why they went out "only once
a few versions" -- the cost was never posting, it was composing a second
piece of prose about a release that had already been written up once.

Nothing new has to be written. `docs/release-notes/next.md` is already
curated by hand per release and already IS the GitHub release body, so
the announcement is a transform of it rather than a fresh document.

`scripts/lib/discord-notes.mjs` handles the gaps between GitHub markdown
and Discord, which are specific rather than general:

  - masked links render ONLY inside an embed, so the notes go in an embed
    and the message content stays a bare ping
  - there are no tables; rows become bullets
  - <details> shows as raw tags
  - headings render, but an embed `##` outsizes the embed title above it,
    so headings become bold
  - the description caps at 4096, and truncation cuts at a bullet
    boundary -- a hard slice can land inside a link and render as broken
    markup, which is worse than saying less

It cuts at the same `<!-- /modal -->` marker the in-app update dialog
uses, for the same reason: an announcement is an interruption. The tail
is counted rather than dropped, so a three-bullet post over a forty-change
release does not read as a quiet week.

`allowed_mentions` is always explicit. Release notes are not written with
`@everyone` in mind and a webhook will happily resolve one that slipped
in; only a configured role id can ping.

Two entry points. `release: published` posts automatically. A manual
`workflow_dispatch` takes `--since <tag>` and rolls several releases into
one post -- the catch-up case that actually happens -- and defaults to a
dry run so a manual trigger cannot surprise anyone. Dry run is also the
CLI default: `pnpm announce --version v2.11.0` prints and posts nothing.

Kept as a separate workflow from release.yml on purpose: a Discord
outage, a rotated webhook or a rate limit must never fail a release that
has already shipped.

Also widens the vitest include to `scripts/**/*.test.mjs`. A broken
announcement transform is otherwise only noticed after it has been
posted.
2026-08-04 19:06:03 +03:00
DumiFlex cc6d76da3f fix(refs): only promise the in-place remap where a host can perform it
The broken-ref card told every surface to "click the chip to point it at
another module". Only `RichTextInput` listens for `remap`.
`RichTextPreview` renders the same chips read-only, and the canvas
derivation modal's summary column is a preview -- so on the surface where
the card was most likely to be read, the one instruction it gave did
nothing.

`remappable` is now an explicit prop, set by the editable input alone.
Read-only surfaces point at the SPA editor instead, which is where the
value can actually be repointed. It defaults to false because a caller
that forgets the prop is precisely the caller with no handler wired:
promising nothing is the safe direction.

Nothing shipped stale -- `web/` is a gitignored local build artifact --
but it is worth recording that a change under `src/manager/` is invisible
in the running SPA until `pnpm build:manager`, and the pre-commit hook
only builds the extension (for the size gate). Touch the SPA, rebuild it
before judging what you see.
2026-08-04 09:33:50 +03:00
DumiFlex 6bd6140ae9 fix(refs): stop a deleted module's refs from rendering as healthy
A ref to a module deleted from the library went on looking fine: right
name, right colour, resolved-ref glyph, and a hover card quoting the
option count the module had BEFORE it was deleted. The node badge said
BROKEN REF while the chip inside the modal disagreed -- the two read
different sources, and only one of them was right.

ROOT CAUSE. When the server confirms a uuid is gone -- a 404, or a 200
that omits it -- `fetchBundle` stamped a permanent tombstone but left the
cached snapshot in place, so `lookup()` kept answering from it and no
consumer ever learned the ref had broken. The rule it was following
("keep the stale value so a label never flickers back to a raw uuid") is
right for a TRANSIENT failure, where the old value is our best guess at
the current one. Once the server says the module is gone, the old value
is not a guess, it is a false statement. Confirmed-missing uuids now
evict; transient ones still keep their snapshot. `lookup` also refuses a
permanently-tombstoned uuid outright, so the fact beats any cache entry
whatever wrote it.

`atomIsResolved` already documented exactly this case -- "a ref pointing
at a deleted library entry should still render unresolved (red chip) even
when the cached label survives" -- and was correct; it just asked a
`lookup` that was lying to it.

THE CHIP. Two more places assumed a resolved ref. `optionStats` counted
from whatever snapshot was cached regardless, and outranks the broken
branch in the template, so a gone module reported "2 options" and hid the
explanation. `poolLabel` printed "pool: library" because that is
`resolvePoolFor`'s fallback for an unknown uuid -- a fallback, not a
finding -- which put "not in the library" and "pool: library" in the same
card. Both now return null for an unresolved ref.

The broken chip keeps the name rather than collapsing to a bare "?": the
`@{uuid#name}` syntax preserves what the target was called when the
reference was written, so it renders as a red `? test` and the hover
names the missing module, says it is not in the library, and says
clicking will remap it. Phrased as "was", since a name we can no longer
verify is exactly what the old card was wrong about.
2026-08-04 09:22:18 +03:00
DumiFlex 433930d005 feat(seeds): persist the frame-grid collapse across a workflow save
The toggle reset to open on every reload, which made it close to
pointless: someone who collapses a 100-frame grid to get their node back
wants it collapsed tomorrow too.

State lives on `node.properties.wp_frame_chips_collapsed`. That is the
workflow-JSON serialization root, and the only place per-node UI state
survives a save -- a WeakMap would be lost on reload, which is exactly
what we are fixing. Key is `wp_`-prefixed per the extension-isolation
convention.

`FrameChips` takes it as a `defineModel`, so it stays usable standalone
(local fallback) while the widget glue owns the value in the app. The
glue keeps a ref as the render source of truth and writes through to
properties, so a toggle repaints immediately instead of waiting on a
poll.

The re-sync in `onValueRestored` is load-bearing: `create()` runs BEFORE
ComfyUI applies a workflow's saved properties, so the first read always
sees nothing. Without it a collapsed grid would come back open and the
persistence would look broken in precisely the case it exists for.

The collapsed header keeps reporting `N locked · N bypassed`. That
mattered when collapse was per-session; it matters more now that the grid
can stay hidden indefinitely, because a forgotten lock is a confusing run
with no visible cause.
2026-08-04 09:02:13 +03:00
DumiFlex d381cc4b06 fix(seeds): put the Seed List chips under the button they belong to
The chips are the inline view of what the per-iteration seeds modal
lists, so sitting above its button made them read as an unrelated
section. Below it they read as its expansion, and the two nodes now
agree: the Context Loop already ordered them this way.
2026-08-04 08:53:51 +03:00
DumiFlex f52d4271dc feat(seeds): frame chips on the Seed List, and a collapsible bounded grid
Extracts the Loop's per-frame chips into a shared `FrameChips` component
and gives WP_SeedList the same surface, because the two nodes were
already showing the same information through different code.

CLICK SEMANTICS DIFFER ON PURPOSE. The Loop has an edit cursor, so a
plain click selects the frame to edit and the state toggles need
modifiers. The Seed List has no cursor and nothing else a chip could do,
so a plain click toggles the lock directly -- making someone hold Shift
for the only available action would be ceremony.

Bypass is asymmetric for the same kind of reason. The Loop OWNS its
bypass set, so Alt-click toggles it. The Seed List only MIRRORS the
bypass of the loop wired into its `loop_config`, so its chips show the
state and refuse to change it: editing there would mean writing to a
different node. Alt-click is inert rather than absent, and the tooltip
names where the setting actually lives, so a click that does nothing does
not read as a bug.

Both nodes now show locked frames too. That state existed and was only
visible inside the modal, which defeats the point of reading it off the
node.

COLLAPSE + BOUNDED HEIGHT. A 100-frame loop previously grew the node
until the chips owned the canvas. The grid now caps at roughly four rows
and scrolls; the wheel shield already installed on every widget host is
what keeps that scroll from zooming the canvas under Nodes 2.0.

The header keeps a `N locked · N bypassed` summary while collapsed --
hiding the grid must not also hide the fact that frames are set, since a
forgotten lock is a confusing run. Only in-range frames are counted: an
out-of-range lock has no chip here, and naming it would point at a frame
that is not on screen.

Collapse is deliberately NOT persisted. Surviving a workflow save would
mean a hidden grid staying hidden across sessions, which is the same
forgotten-lock trap. Re-opening costs one click.

Test ids for the chrome are pluralised (`loop-frames-grid`) so they do
not collide with the chips' own `loop-frame-` prefix, which suites count
with a prefix selector.
2026-08-04 08:51:33 +03:00
DumiFlex 6cf4990513 feat(loop): lock and bypass a frame straight from its chip
Both states were already VISIBLE on the frame chips -- a dashed border for
bypassed -- but only CHANGEABLE from the per-iteration seeds modal, which
is two clicks away and covers the node you were reading. Flipping one or
two frames is the common case, so it should not cost a round trip through
a dialog.

  Shift-click  toggle the frame's seed lock
  Alt-click    toggle bypass

Ctrl is deliberately unused: on macOS Ctrl-click IS a right-click, so the
combo would open a context menu instead of firing. Alt for bypass follows
the idiom already in the seed modal, where Alt-click on Copy takes the
locked-only variant.

A chip-lock writes the seed the frame WOULD have used, from the same
`deriveLoopSeeds` series the modal's "Lock all" writes, so the two agree
-- a lock that froze a different value than the one displayed would be
worse than no lock.

Bypassing the last running frame is refused, mirroring the modal's own
rule, and says why with a toast. A silent no-op would read as a broken
click, and nothing on the chip hints at the reason.

A modifier click never also moves the edit cursor. Jumping the editor to
another frame as a side effect of locking one is the kind of surprise
that makes people stop trusting a shortcut. `base` takes no modifiers at
all: it has no seed to lock and nothing to bypass.

Locked frames now get a corner dot -- the chips are ~20px tall so a
padlock glyph would be mud, and the tooltip carries the actual seed. The
dot survives bypass, or unlocking later would look like it did nothing.
Every chip is `position: relative` whether locked or not, so toggling one
cannot reflow the grid.

The combos are printed under the grid rather than left to tooltips. An
undiscoverable shortcut is the same as no shortcut, and that line is
where the reader is already looking.
2026-08-04 08:37:37 +03:00
DumiFlex ffd0a5e709 feat(spa): move what's new to its own page behind a topbar dot
As a dashboard card it held the top of the home view permanently to
deliver something read once, and competed with the hero for the first
thing a returning user sees. A topbar button with an unread dot costs a
few pixels, says "there is something here" just as clearly, and -- unlike
a dismissible card -- is still there next week when someone wants to
check what changed.

That last part fixes a gap the card could not. The update pill renders
only while a NEWER release exists, so once you were current there was no
way back to the notes for the version you are actually running. This page
is now the permanent home for them, which is what makes the unread dot
safe to clear on sight: nothing is lost by not reading it today.

The page shows the full notes, including the "Also in this release" tail
the update dialog cuts. The dialog cuts it because it is an interruption
and should stay short; a page the reader chose to open is not, and
withholding half the notes there would just send them to GitHub. The
per-commit dump stays stripped in both -- that is for people reading git.

Icon is a megaphone, with the dot moved to the BOTTOM-right rather than
the icon changed to suit the dot. Top-right is the conventional corner
but it is also where the horn is, so the dot merged into the glyph and
stopped reading as a marker; bottom-right is the one corner the shape
leaves empty. Tried and rejected: `pi-gift` (says reward), `pi-bell`
(promises actionable alerts this does not deliver) and `pi-sparkles`
(the wildcard kind icon everywhere else).

Also restores mocks between AppTopbar tests. The update-pill test spies
on `useReleaseCheck` and pins `current` to a fixed version; module spies
outlive the test that made them, so every later test in the file was
silently running against that stub. The new dot tests failed on it while
passing in isolation, which is the visible symptom of a hazard that was
already there.

Verified in the running SPA: 13 checks covering the dot lighting on a
version change, clearing on visit and staying clear across a reload, the
page rendering the tail the dialog omits, and the no-notes-cached state.
2026-08-04 08:29:27 +03:00
DumiFlex 845e03c877 fix(spa): stop burning the GitHub quota, and make the cards fit
Three defects, found by opening the page instead of reading the diff.

RATE LIMITING. The launch check was spending far more of the anonymous
60/hr-per-IP allowance than "once per session" implied, in two
independent ways.

The `sessionFetched` guard is a module-level binding, so it dies with the
page: the real cadence was one request per RELOAD, and a day of working
on the SPA is easily sixty. Freshness is now judged from the persisted
`checked_at` the cache was already writing and never reading, with a
6-hour TTL. `checkNow()` still ignores it, because the user asked.

Worse, the guard was set only once a response LANDED. Every consumer
mounting in the same tick -- topbar pill, Dashboard, Settings -- read it
as false and opened its own request, so a single load cost three units,
not one. It is now set on attempt, and concurrent callers share one
in-flight promise.

When GitHub does refuse us, the reset it hands back in X-RateLimit-Reset
is honoured rather than retried against, and persisted, so the reload
that follows a 403 does not immediately spend another request. The
previously cached release survives a refusal -- forgetting the version we
already knew because the quota ran out would be a second failure on top
of the first.

WHAT'S NEW WAS STRUCTURALLY DEAD. `hasUnseenRelease` needs a previously
recorded version, and the only writer was the card's own dismiss button,
which never renders until the card shows. A closed loop with no entry
point -- it could not have fired for anyone, ever. The running version is
now recorded on first sight, so the NEXT upgrade has a baseline. Tests
that seeded `seenVersion` by hand never exercised the path the app takes,
which is why they passed against a feature that could not run.

STYLING. Both cards styled themselves from scratch in `--wp-node` -- the
CANVAS node palette, teal -- which is the graph's colour language, not the
manager's. They now use the same Card/Button/Icon primitives and accent
purple as everything around them, and the what's-new list gets an
explicit `list-style` because the app reset clears it globally and the
entries were running together unmarked.

The star prompt also moves to the BOTTOM of the dashboard. Above the hero
it was the first thing on the page, louder than the product's own
welcome -- the layout of an ad rather than of an aside. The eligibility
rule already selects a returning user; it does not need the best seat.

Verified in the running SPA, not just in jsdom: 15 checks covering both
dismiss buttons persisting across reload, the snooze expiring, the
what's-new dismiss, four reloads making zero GitHub calls, and a stale
cache making exactly one.
2026-08-04 08:11:39 +03:00
DumiFlex de361d3fab feat(spa): a what's-new card, and one quiet request for a star
Two Dashboard cards, both inline and dismissible. Neither blocks
anything and neither is a modal — the extension already raises an update
dialog, and a second one on the next SPA open is the same news twice.

WHAT'S NEW reuses the release body the update check already fetches, cut
at the modal marker, so it shows the same highlights-only slice for the
same reason. Silent on a fresh install: someone who has never recorded a
version has no "new" to hear about. It also only renders when the fetched
release matches the INSTALLED version — while an update is pending the
body describes changes the user does not have yet, and presenting that
as "what's new in your version" would be a lie.

THE STAR PROMPT is a favour, so it behaves like one. The obvious
triggers were all first-touch — "created N modules", "ran the starter",
"first test run" — and every one fires on someone still poking at the
thing: ten modules can be ten empty rows, and the starter bundle is by
definition the moment before you understand anything. Those measure
curiosity, not value.

So it needs two signals, both: five distinct calendar days with the
manager open (not sessions -- a reload is not a return; not consecutive
-- life happens), AND a library carrying real content. Either alone is a
false positive.

Existing users are credited on first run: without it, tracking starts at
zero for everyone and a three-month user waits another five days, which
is backwards. The credit is deliberately short of the threshold, so they
still have to come back -- being asked the instant you update reads as a
reward for updating.

"Maybe later" hides it for a fortnight then lets it return, because a
button that visibly does nothing is a broken button and "later" is not
"no". "Don't ask again" is permanent and survives upgrades. The star
count is not shown: social proof cuts both ways, and at a low number it
argues against starring.

25 tests on the eligibility rule, which is the whole feature -- fire
early and it is adware, never fire and it is dead code.
2026-08-04 07:49:52 +03:00
DumiFlex 548b63edc9 feat(spa): tell the update dialog how much it is not showing
The dialog cuts at the modal marker, so it shows the headline and
highlights and then simply stops — a five-change release and an
eighty-change one look identical from inside it, which is the opposite
of what the cut was for.

`tailChangeCount` counts the bullets below the marker and the dialog
renders "+ N smaller changes" beneath the notes. Zero for releases
published before the marker existed, so nothing is claimed about them.

Verified against the real next.md: 20 counted, matching the 20 written
by hand in the <details> summary on the GitHub side. The template now
notes that the GitHub number is manual and the dialog's is computed, so
if they ever disagree the hand-written one is the wrong one.
2026-08-04 07:27:18 +03:00
DumiFlex 51286b3273 fix(docs): spread the figure wash across the panel, not the corner
The CSS this mirrors is `radial-gradient(120% 100% at 0% 0%, …)` — an
ellipse 120 percent of the WIDTH by 100 percent of the HEIGHT, anchored
top-left, so it falls off across the whole panel. I had applied a
`scale(1, h/w)` transform, which squashed it into a flat smear along the
top edge; on an 880x202 frame that reads as a tinted corner with three
quarters of the figure left flat.

Now `r=1` with `scale(1.2 1)`, which is the CSS geometry directly, and a
three-stop falloff so the tint carries further before it disappears.
2026-08-04 07:23:11 +03:00
DumiFlex 157606b082 fix(docs): restore the figures' depth, and draw the loop_config link
Three things, all of them fidelity to the SPA figure rather than taste.

The frame lost its wash. PipelineDiagram paints
`radial-gradient(120% 100% at 0% 0%, node 6%, transparent 60%)` over
bg-1; dropping it left a flat slab. Reproduced as a radial gradient with
themed stops.

The connectors lost their fade. `.wp-doc-diagram__arrow-ln` is a
gradient that fades in and out at both ends, so a connector reads as
flow rather than a drawn border -- which is most of why these felt dead.
They are RECTS, not lines: a horizontal <line> has a zero-height
bounding box, so an objectBoundingBox gradient on it collapses and the
line vanishes, leaving an arrowhead pointing at nothing. Caught that in
the render, not in review.

And the loop diagram was topologically incomplete. WP_ContextLoop has a
`loop_config` side-output; WP_SeedList takes it as an optional input and
mirrors the loop's count, strategy and base seed from it. That link is
exactly WHY the two fan out in lockstep, and the old figure -- and my
first rebuild -- drew them as unrelated sources, leaving the caption's
claim unexplained.
2026-08-04 07:21:28 +03:00
DumiFlex 2f40c921eb fix(docs): colour the flow-figure icons instead of leaving them black
The icon extractor rewrote `fill` attributes to `currentColor`. Almost
no PrimeIcon HAS a fill attribute -- of the ones these figures use, only
`sparkles` declares one -- so a rewrite-what-exists pass left every
other glyph at SVG's default black.

That is why the feeder pills looked right while every node icon came out
black: the feeders happened to use icons carrying fills.

Concrete fills are now stripped and the wrapping group carries
`fill="currentColor"`, so anything without its own fill inherits the
group colour. `fill="none"` is preserved -- on the icons that use it, it
marks the deliberately hollow part, and filling it would blob the glyph.

Verified rendered in both themes: WP nodes teal, external nodes muted,
feeder pills in their per-kind colours.
2026-08-04 07:17:28 +03:00
DumiFlex 4b69068ace docs: rebuild the README flow figures from the SPA's design system
The two figures were hand-drawn SVG that had drifted from the app: flat
horizontal cards, approximated icon shapes, a violet accent the product
does not use, and — in the loop diagram — the assembled prompt running
straight into KSampler. It does not go there. Text is encoded first and
only conditioning reaches the sampler; seeds are the one thing that
arrives directly.

Both are now generated from the real component. PrimeIcons ships
`raw-svg/`, the same paths the webfont is built from, so the README
carries the actual glyphs rather than lookalikes, and every value is
lifted from PipelineDiagram.vue and theme.css instead of eyeballed:
teal `--wp-node` accent, `--wp-bg-2` cards at radius 10, vertical
icon/name/mono-subtitle layout, kind-coloured feeder pills, per-chip
accent borders, 9.5px uppercase connector labels.

A `prefers-color-scheme` block mirrors the SPA's light theme, so the
figures follow the reader's GitHub theme rather than sitting as a dark
slab on a light page. `pnpm docs:figures` regenerates them.

The SPA components are untouched — they already theme correctly.

Also: the release-notes tail moves into a <details>. On a release this
size it would otherwise double the page. The summary carries the count
so the reader knows what they are opening; the cost, noted in the
template, is that collapsed text is not reliably reachable by
find-in-page. The modal is unaffected — it already cuts above this.
2026-08-04 07:09:56 +03:00
DumiFlex 0e1b36f6e4 build(release): rewrite the release-notes template
The old body was a flat curated list followed by the per-commit
changelog inside a <details>. Two problems, both visible on 2.11.0.

The commit list restated the curated notes. Hidden types were already
filtered out, so what remained WAS the user-facing changes -- the same
content at lower quality, growing linearly with the release. Dropped in
favour of a compare link, which is one line and strictly more useful:
diffable, searchable, and the real archive is git anyway.

The curated list had no hierarchy, so "long values wrap in a text box"
sat at the same weight as a correctness fix, and length scaled with the
release. It is now:

  Headline
  ### Highlights            4-5, capped
  <!-- /modal -->
  ### Also in this release  grouped one-liners
  ### Upgrade notes         only when the reader must act

The cap is the mechanism -- it is what keeps a 6-commit release and an
80-commit release roughly the same height. next.md carries the
selection rules as comments so the next writer does not have to
re-derive them.

The modal now stops at the marker. It has a 320px scroll box and one
job -- answer "is this worth clicking Update now" -- and was rendering
the entire body into it, slicing a paragraph mid-sentence. Bodies with
no marker still render whole, so every published release is unaffected.

Also replaces the stale next.md, which still held the already-published
2.11.0 text and would have shipped again verbatim. It now describes the
81 unreleased changes on this branch: 5 highlights, 20 grouped
one-liners, 291 words to the modal against 686 for the page.
2026-08-04 06:18:28 +03:00
DumiFlex dafb7d879c fix(cleaner): stop rewriting embedding and LoRA references (P3 #31)
The cleaner treats a prompt as prose or as a comma-separated tag list.
That is right for tags and wrong for the few tokens that are ADDRESSES
rather than words: `embedding:name` and `<lora:name:1.0>` resolve to a
file, so one edited character changes what the sampler loads, or loads
nothing.

Two rules were provably destructive:

  embedding:bad_prompt_                        -> embedding:bad_prompt
  embedding:badhandv4, embedding:badhandv5     -> embedding:badhandv4

`_` and `-` are in the stripped punctuation set, and `bad_prompt_` is a
real published shape. Versioned names sit far above the hardcoded 0.9
fuzzy threshold while naming different files. Both failures are silent:
ComfyUI resolves a missing embedding to nothing, and the dropped tag
only shows up in a generic "N removed" count.

engine/cleaner/atoms.py marks both forms -- LoRA included, since it has
the identical failure mode and excluding it would have been an
arbitrary line. Punctuation stripping skips atoms; fuzzy dedupe falls
back to exact comparison when either side is one.

This widens the cleaner's remit from "text" to "knows a little prompt
syntax", which the findings named as the cost. The alternative was a
rule that renames a file reference without saying so.

Narrow, and the narrowness is tested: ordinary tags still get trimmed,
ordinary near-duplicates still merge, exact repeats of an embedding
still collapse, and text mode still trims a leading edge -- it only
holds back the trailing trim when the prompt ENDS in an atom.
2026-08-04 05:59:08 +03:00
DumiFlex 6332ed1e4f perf(graph): memoise the widget parse the poll walkers share (P3 #29)
reactiveFromGraph installs a 400ms poll per call site, and a single
WP_Context node installs TEN of them. Every walk re-read the node's
wp_modules widget and re-parsed it from scratch -- on a real workflow
that string is ~294 KB, so the parse cost MORE than the traversal it
fed (JSON.parse 4.64 ms/call against 4.02 ms for the whole of
collectUpstreamProducers). Ten pollers parsed the identical string ten
times per cycle; five nodes made it ~21% of a core.

Cache keyed on the raw string, so it self-invalidates when the widget
is edited, and bounded at 24 entries because every keystroke in an
editor mints a new 294 KB key.

The findings flagged the hazard: callers now share one object instead
of each getting a private copy. That is why this lives in graph.ts as a
module-private helper rather than inside parseWidgetJson, whose other
callers (ContextWidget) legitimately mutate what they get back. The
read-only contract is enforced, not assumed -- graph.parse-cache.test.ts
puts the cache in freeze mode and runs every walker, so a write throws
instead of silently corrupting the next reader.

Measured on the same 5 x 18 x 133 graph as before:

  collectUpstreamProducers      3.67 ms -> 0.06 ms
  collectUpstreamVariables      9.39 ms -> 1.99 ms
  collectUpstreamWildcardUuids  3.69 ms -> 0.01 ms
  per cycle, 5 nodes           83.79 ms -> 10.33 ms
                               ~21% core -> ~2.6%

Collapsing the ten polls per node into one shared walk stays
un-attempted; this may make it unnecessary.
2026-08-04 05:55:04 +03:00
DumiFlex d9e4a7c262 build: scope the pre-commit gate to what changed
Every commit ran the whole CI suite -- lint, typecheck, the full 4000-test
vitest run, a build, the size gate, ruff and the full pytest suite --
regardless of what the commit touched. Measured on this machine that is
about 4.5 minutes, and since the habit was to run the same gates by hand
first, the real cost per commit was closer to nine.

The hook now looks at the staged paths and runs only the halves that can
be affected, with vitest scoped to the changed sources via `vitest
related` (30s against 150s; it still walks the import graph, so a shared
module pulls in its dependents). WP_FULL=1 forces everything.

This is only safe because CI now runs on every branch push rather than
just main -- a feature branch previously had no remote verification at
all until a PR existed.
2026-08-04 05:48:14 +03:00
DumiFlex 0765df3579 fix(chips): real hover cards on option chips, producers in derivations
Three corrections, all of them to my own earlier attempt.

Option chips: these were hand-rolled spans that only MIRRORED RefChip's
styling, so they were the one surface in the editor with no hover card.
The previous commit made that worse by bolting on plain `title`
tooltips instead of using the component we already have. Now they ARE
RefChips, so they get the same card as every other chip. Two test
expectations move to RefChip's conventions, which is the point of the
change: `@` is prefixed only once a ref resolves, and the filter
expression lives in the card rather than a native title.

Derivations: rule-summary chips said "binds at runtime" for variables
with an obvious producer. RuleValueChips rendered RichTextPreview with
only value/uuid-to-name/surface, and RichTextPreview had no producer
props at all, so every var chip below it fell back. Producers now flow
RulesSection -> RuleValueChips -> RichTextPreview -> RefChip, and the
same chain is wired for wildcard option chips
(ModuleEditModal -> WildcardInstanceModal -> PoolSection -> OptionRow).
Combine already worked, which is why the symptom looked
derivation-specific.

Type-to-filter hint: shipped behind a six-option threshold, which hid
it on exactly the menus being checked -- a three-option dropdown showed
nothing. Threshold is now two, so it appears wherever there is anything
to filter.
2026-08-04 05:35:56 +03:00
DumiFlex 2b00a5970c feat(ui): hover text for option chips, and telegraph type-to-filter
Two discoverability fixes.

Chips inside a wildcard option had no hover text in the ordinary case.
A nested-ref chip only carried a `title` when it was unresolved or
filtered, and `$var` chips carried none at all -- so a resolved ref,
which is the common case, gave no way to see which library entry it
points at. The chip label is abbreviated to `@name` with no uuid, so
there was nothing else to go on either. Ref chips now always describe
themselves (name, uuid, and any filter), and var chips say what they
are.

Every Select menu has always filtered as you type, but the only sign of
it was the "Filtering:" line, which appears AFTER the user has already
guessed the feature exists -- as a user put it in Discord, "perhaps it
should be telegraphed better". The menu now says "Type to filter" up
front, on lists of six or more where filtering is worth reaching for,
and gives way to the live filter line on the first keystroke. Dropdowns
that already carry their own search field are separate components and
are untouched.
2026-08-04 05:02:57 +03:00
DumiFlex 22b6597ec7 Revert "fix(nodes2): re-baseline every widget when the renderer flips live"
This reverts commit c421675102.
2026-08-04 04:51:03 +03:00
DumiFlex c421675102 fix(nodes2): re-baseline every widget when the renderer flips live
The Nodes 2.0 toggle does not reload the page, so every widget alive at
that moment was built against the other renderer. The damaging piece of
staleness is the autosize bookkeeping: pushSize compares the node's
current height against the last height WE set and treats a mismatch as
"the user dragged this, leave it alone". A renderer flip changes node
height without going through us, so from that instant every widget
decides the user owns its height and stops auto-collapsing.

That is the same trigger behind the Debug node's jump, and it explains
why WP_VarToInt/Float stay tall after their dropdown closes but only
following a LIVE switch -- after a page refresh they are fine.

One watcher, driven by the DOM churn the flip itself causes, tells every
widget to forget its baseline and re-fit. It is not specific to the two
nodes reported: any widget that autosizes was exposed.

Not reproduced end-to-end: the var picker only grows enough to show the
bug when it has upstream variables to list, and an unwired node's menu
is empty. The mechanism is covered by unit tests instead -- a flip
triggers a re-fit, a non-flip does not -- and the browser checks confirm
no regression in either renderer.
2026-08-04 03:42:19 +03:00
DumiFlex 021b411e3c fix(ui): platform-correct save chord, and a legible bundle summary
Two small ones.

The modal footers rendered a hardcoded `⌘↵`, which reads as a Mac-only
binding to the overwhelming majority of ComfyUI users. The handler has
always been `(ev.ctrlKey || ev.metaKey) && Enter`, so only the LABEL was
wrong -- it now says `Ctrl+↵` off Apple platforms. Detection prefers the
UA-CH platform hint and falls back to the user-agent string, avoiding
the deprecated navigator.platform.

The bundle header's child-count summary ("9 mods") was 10px in
--wp-text-dim, which resolves to #666 on the dark theme and sat right at
the edge of legibility against the bundle frame tint -- the one thing
telling you how big a collapsed bundle is. Now the muted token at 11px
with a faint pill, so it reads as a value rather than a watermark.
2026-08-04 03:30:16 +03:00
DumiFlex 312e1575ae fix(modals): shield keys that never traverse the modal
The root-level shield only sees keys TARGETED inside the overlay. Click
the button that opens a modal and focus stays on that button, which is
outside the overlay, so keystrokes go straight from it to window and the
shield never runs. Measured on the Blocklist modal: pressing a, a, r
with the modal open put ["a","a","r"] on window.

Two additions:

- focus the overlay when it opens, unless something inside already has
  focus (an autofocused field must keep it);
- guard at the document for keys targeted outside the open modal, since
  a modal owns the keyboard while it is up. Document bubble runs before
  window, where ComfyUI's keybindHandler lives.

Escape still passes through, so close handlers keep working.

Verified in a browser, Blocklist modal open:

  before   focus BUTTON (the trigger), keys to window ["a","a","r"]
  after    focus moves into the overlay, keys to window []
  typing   "abc" still lands in the textarea, nothing leaks
2026-08-04 03:14:34 +03:00
DumiFlex 9e8cd23fe5 fix(modals): stop keystrokes escaping to ComfyUI's global shortcuts
Pressing `r` inside an edit modal ran ComfyUI's Refresh Node Definitions
and froze the page.

ComfyUI binds `useEventListener(window, 'keydown', keybindHandler)`, and
the handler only bails for text-editing targets:

  const target = event.composedPath()[0]
  if (keyCombo.isReservedByTextInput &&
      (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT' ||
       target.contentEditable === 'true' || ...)) return

A plain letter pressed while focus sits on a button or a div inside a
modal is not reserved by text input, so it runs the global keybinding.
Its other guard -- skip global keybindings while a dialog is open -- keys
off ComfyUI's OWN dialog stack, which our modals never enter.

useModalKeyShield binds keydown/keyup to the modal ROOT in bubble phase.
Everything inside the modal has already had its turn by then, so inner
handlers, input defaults and IME are untouched; the event simply stops
before it can climb to window. stopPropagation only -- never
preventDefault -- so typing still works.

The corollary is that a modal's own key handling can no longer live on
window: a window listener sits ABOVE the shield and would never see keys
pressed inside its own modal. ModalShell now re-emits them, and the two
consumers that had window listeners are moved onto it:

- ModuleEditModal (Ctrl+Enter to save)
- BundlePickerModal (arrow/Enter/"/" navigation)

ModalShell keeps a window listener for Escape only, which covers the
case where focus is OUTSIDE an open modal.

ModulePickerModal already used a capture-phase window listener, which
runs before the shield, so it is unaffected.

Verified in a browser: keys pressed inside a ModalShell modal no longer
reach window, and Escape still closes it.
2026-08-04 02:52:03 +03:00
DumiFlex a769c0e496 fix(nodes2): survive a live renderer toggle with content on screen
Switching the renderer from the menu does not reload the page, so a
widget built under legacy never ran any construction-time isVueNodes()
branch, and the fill observer's bounded retry (~10s of frames, looking
for a node element that does not exist yet in legacy) had long since
given up. Toggling with content on screen therefore left the layout
unapplied: measured, the node jumped from 380 to 1200 and could no
longer be resized. Reproducing it needed a wait past the retry budget,
which is why the earlier reload-based test showed it passing.

Two changes:

- Apply the out-of-flow layout at construction, in BOTH renderers, and
  stop stripping it in the legacy branch. The push needs only one frame,
  so there must be no window at all. Legacy is indifferent -- it gives
  the host a definite box either way, and its numbers are unchanged
  (host 354u, scroller engaged, 500x240 floor, wheel scrolls 0 -> 300).
- Replace the bounded retry with a registry driven by one document-level
  observer, so a node element that appears minutes later is still
  picked up.

Verified: toggling live after the old budget would have expired now
keeps size at 380, the scroller engaged, and manual resize working.
2026-08-04 02:27:35 +03:00
DumiFlex 99125a63d7 fix(nodes2): fill the node instead of pinning it, and keep the wheel scrolling
Two follow-ups to 27685706, which fixed the Debug node inflating but by
pinning the host to a pixel height computed from node.size.

1. The pin was the wrong lever. The node element's height is
   authoritative, so the correction term sat at roughly zero: the host
   never grew with the node and drifted a couple of pixels smaller on
   every resize. The box stopped tracking the node and shrank each time
   it was dragged.

   The real cause is narrower than "the percentage height does not
   resolve". Every ancestor between us and the node DOES have a definite
   height tracking node.size -- measured 238 / 638 / 838 as the node
   went 330 / 730 / 930. What breaks is that we are a flex item, and a
   flex item defaults to min-height: auto, which refuses to shrink below
   its content. The payload grew our box, which pushed the chain, which
   the layout store wrote back into node.size.

   Three parts, each needed:
   - min-height on the host so it can shrink below content. Fixed, not
     content-derived: the host clamps a resize with
     measureMinContentHeight(), which MEASURES our content, and with the
     content out of flow that measured zero and took the floor with it
     (dragged to 62u where legacy stops at 240).
   - content out of flow. Our own min-height is not sufficient because
     EVERY ancestor is also min-height: auto, so the push propagates
     past us -- measured, the node still inflated 300 -> 870.
   - both applied at widget construction, not on a deferred sync: Vue
     paints content before any observer of ours runs, and one frame of
     in-flow content is enough for the store to record an inflated size
     that then sticks across a renderer switch.

2. The wheel zoomed the canvas instead of scrolling our widgets. The
   canvas yields a wheel only to a widget that declares
   data-capture-wheel="true" AND currently holds focus; a read-only
   viewer never qualifies. A bubble-phase stopPropagation on our own
   element does not reach that decision either. Measured over the Debug
   scroller:

     legacy      scrollTop 0 -> 300   scale 0.900 -> 0.900
     Nodes 2.0   scrollTop 0 ->   0   scale 0.900 -> 0.818

   Absorb it at window capture, which runs before every other listener,
   using stopPropagation alone so the browser still performs the native
   scroll. Ctrl+wheel is left to the canvas: that is pinch zoom. Applied
   to every widget host, not just the Debug viewer.

Verified in a real browser, both renderers:

  host tracks the node   238 / 638 / 838 for node 330 / 730 / 930
  payload does not push  size[1] holds at 400 for 3, 20 and 60 vars
  scroller engages       pre 289/1089
  wheel                  scrollTop 0 -> 300, scale unchanged
  renderer switch        size stays 380, no jump to content height
  floors                 Nodes 2.0 390x262, legacy 500x240

Legacy numbers are identical to before the change.
2026-08-03 23:29:14 +03:00
DumiFlex 2768570660 fix(nodes2): stop the Debug node inflating to fit its payload
fillHost means the user owns the node's height and the widget scrolls
whatever overflows. That rests on `height: 100%` resolving -- and a
percentage height against an INDEFINITE container computes to `auto`.
Legacy gives the host a definite box so it resolves. Nodes 2.0 lays
nodes out from content, so it does not: the flex column sizes to
content, .wp-dbg-pre (flex: 1 1 0%; min-height: 0) is handed its full
content height instead of being compressed, overflow: auto never
engages, and the node inflates to the whole payload -- which the layout
store then syncs back into node.size.

Measured with node.size[1] pinned at 400:

  payload    legacy host / pre          Nodes 2.0 host / pre
   3 vars    354u  305/305              338u  289/289
  20 vars    354u  305/429  scrolls     477u  429/429  never scrolls
  60 vars    354u  305/1089 scrolls    1138u 1089/1089 never scrolls

One mechanism, three reported symptoms: the dead scrollbar, the node
that cannot be dragged shorter, and the absurdly tall node.

Pinning a pixel height alone is NOT enough. The Nodes 2.0 parent is
.flex.flex-col.*:flex-1, and that `*:flex-1` hits every child including
us; with flex-basis 0% and flex-grow 1 the main-axis size comes from
flexing and an explicit height is ignored. Measured: host pinned to
308px still rendered 1024px. So opt out of flexing too.

Height comes from node.size[1], which is authoritative in both
renderers, via an error-correcting step rather than a modelled chrome
constant -- compared against size[1] + header, because the host's own
sync notes that layout size excludes the title.

Width: legacy gets its floor free from computeSize, which Nodes 2.0
never calls, so the node could be squeezed to MIN_NODE_WIDTH (225)
against a widget declaring 494. Its resize interaction reads the floor
from the node element's inline min-width, so publish ours there --
writing node.size[0] does not work, the layout store owns the rendered
size and syncs one-way.

After, Nodes 2.0 matches legacy: size[1] holds at 400 for every payload,
the pre scrolls (283/1089), and dragging shrinks to a 390x348 floor
instead of 225.

Only WP_Debug sets fillHost, so nothing else is affected.
2026-08-03 22:39:44 +03:00
DumiFlex 12d6f99104 fix(nodes2): keep the wire merge applied, and stop doubling the pin label
Four defects in the Vue-renderer wire merge, all from the same place:
the merge is DOM state, and it was bound to one element at one instant.

1. Merged rows printed their labels too. They are zero-height and
   stacked on the kept row, so "input_1"/"input_2"/"input_3" piled on
   top of the unified "inputs xN" and rendered as a smear. They keep the
   label override -- it has to be ready the moment they expand -- so
   suppress only their generated text.

2. No clearance under the merged pin. Zeroing the merged rows reclaimed
   all their vertical space, leaving the incoming wires running into the
   top edge of the widget body. Reserve some back on the last merged
   row: they share a y, so only its bottom margin moves what follows.

3. Switching renderers while collapsed came back expanded. At attach
   time the node's element usually does not exist yet -- the workflow is
   restored before Vue mounts nodes -- so the one-shot lookup bailed and
   nothing was left watching.

4. Focusing a collapsed node expanded its wires while the button still
   read "collapsed". Vue REPLACES the node element on re-render, so the
   per-element observer was watching a detached tree.

3 and 4 are both fixed by registering nodes and re-applying from a
single observer on a stable root. childList only: our own writes are
class and style attributes, which that never sees, so there is no
feedback loop.

Labels also now publish in BOTH states, not just while collapsed. Vue
renders slot names from its own snapshot and never sees a slot.label
write, which breaks the restore too: a legacy collapse serializes the
placeholders into the workflow, so reloading under Vue showed "inputs
xN" and " " forever, even expanded.

Verified in a real browser. Legacy collapse -> switch renderer -> reload:

  collapsed   4 pins at 1 distinct y, label "inputs x3", 12px clearance
  expanded    4 pins at 4 distinct y, labels input_0..input_3 restored
  focus       stays merged, 1 distinct y
2026-08-03 20:40:50 +03:00
DumiFlex 97a50ff452 fix(nodes2): merge injector wires under the Vue renderer
The wire merge was a canvas paint-time trick: override getInputPos and
every matched pin reports the first pin's position. Nodes 2.0 paints no
canvas for slots. It renders one .lg-slot--input row per node.inputs
entry and feeds those elements' measured rects to the layout store,
which is what the link renderer draws to, so the override moves nothing
there.

Measured with four wires merged: every pin reported the same
getInputPos while the DOM dots stayed 18px apart. Half the mechanism
did work -- mutating slot.label writes to the data model -- which is why
the node read "inputs xN" while the wires stayed fanned out.

The host has this feature internally: NodeSlots.vue takes a `unified`
prop that grid-stacks every dot into one cell. It is wired only to
whole-node collapse, with no per-node opt-in, so reproduce the effect on
the rows we own. Zero the merged rows' height so they share one centre
line, and pull the first up half a row to land on the kept pin. Height
rather than display: useSlotElementTracking discards any slot measuring
zero area, so hiding a row would drop its link endpoint instead of
moving it.

The label needs the same treatment for the same reason -- Vue renders
slot names from its own VueNodeData snapshot and never sees the
slot.label write (measured: model "inputs x4", rendered "input_0") --
so the collapsed label is published as a custom property and printed
from CSS.

Verified in a real browser, both renderers. Nodes 2.0, four wires:

  expanded   5 dynamic pins at 5 distinct y  [270,288,306,324,342]
  collapsed  5 dynamic pins at 1 distinct y  [270]   upstream stays 252
  expanded   5 dynamic pins at 5 distinct y  (round trip restores)

Also guard CSS.escape, which is absent in some environments.
2026-08-03 20:16:44 +03:00
DumiFlex b712394648 fix(nodes2): anchor the focus flash to the node's real box
The focus-flash border mapped graph coords to screen itself
(canvasRect + (pos + ds.offset) * ds.scale, plus a TITLE_PAD constant).
That derives the box from node.size, and under ComfyUI's Nodes 2.0
renderer node.size is a hint rather than a fact: the node is laid out
from its DOM content and never clamped back. A node declaring 400 wide
measured 490 graph units, so at 0.9 zoom the border came out 81px too
narrow and cut through the node.

Measure instead of recompute. Nodes 2.0 gives each node a
[data-node-id] element whose rect is the box, title bar included, which
also retires the TITLE_PAD estimate. The legacy renderer paints nodes on
the canvas and has no such element, so it keeps the transform maths --
which is correct there: both renderers agreed on position to the pixel,
only the width was wrong.

Verified through the real path (Assembler with an unresolved $var ->
warning toast -> Focus click) in both renderers:

  Nodes 2.0  overlay 584,320 432x291  node 584,320 432x291  (exact)
  legacy     overlay 584,325 432x247  (transform-maths fallback)
2026-08-03 18:53:29 +03:00
DumiFlex 72d9a17f89 test(p3): measure the poll cost and pin the embedding round-trip
Two reproducible probes backing the P3 investigations. Neither changes
behaviour; both exist so the findings can be re-run rather than re-argued.

`src/extension/__perf__/poll-cost.test.ts` times the graph walkers on a graph
shaped like the reported workflow — 5 Context nodes, 18 modules each, 133
options. It measures rather than asserts: the thresholds are loose so a busy
machine cannot turn it red, and the console output is the deliverable.

It shows the 400ms interval is not the expensive part. A Context node installs
TEN `reactiveFromGraph` polls, each re-reading the node's `wp_modules` widget
through `parseWidgetJson`, which is a bare `JSON.parse` with no memoisation.
That JSON is 294 KB per node, and parsing it costs slightly MORE than the
traversal it feeds — so the same string is parsed ten times per node per cycle
for nothing. Three of the ten polls across five nodes come to 84 ms every
400 ms, about a fifth of a core.

`tests/engine/cleaner/test_embedding_roundtrip.py` pins what the Prompt Cleaner
does to `embedding:` syntax. The engine itself never touches it — one unrelated
comment is the only hit in `engine/` — so the cleaner is the whole hazard, and
two of its rules damage embeddings:

- Punctuation stripping trims `_` and `-` from a tag's trailing edge, so
  `embedding:bad_prompt_` becomes `embedding:bad_prompt`. ComfyUI resolves a
  missing embedding to nothing, so the prompt quietly stops working.
- Fuzzy dedupe at its hardcoded 0.9 threshold treats `badhandv4` and
  `badhandv5` as the same tag and drops one. Different files, different
  training, no warning beyond the generic removed-count.

Those two tests assert the CURRENT behaviour and say so in their names, because
whether to teach the cleaner about prompt syntax is a design decision — and the
same argument would apply to LoRA tags, which have the same shape.

Written up in full, with the Nodes 2.0 and capability-survey findings, at
docs/investigations/p3-findings.md (untracked, per the docs allowlist).
2026-08-03 15:52:33 +03:00
DumiFlex 01de4f0f10 fix(tag-menu): bound the menu by the room it has, capped to a readable height
The previous attempt capped the menu's own height at `100vh - 24px`, which
cannot work: whether a menu fits depends entirely on where its top edge landed.
A 400px menu opened at y=700 overflows however small that cap is, because the
cap never knew about the 700. The height is now measured from the trigger to
the edge of the window.

That alone made it fill the whole page on a tall window, which is worse than
the overflow it fixed — one menu burying the page behind it, and a dozen tags
spread over 800px are harder to scan, not easier. So the viewport is the LIMIT
and 340px is the target: roughly a dozen rows plus the search box, shrinking
only when there is less room than that, with a 140px floor below which it
scrolls instead.

The filter menu also flips above its trigger when the room below is genuinely
worse, which the per-option picker already did. In practice the filter never
flips — its button lives in a sticky header near the top — but the row-level
picker does, and both now stay inside the window.

Measured at 1000 / 760 / 620 / 500px viewport heights, on both hosts: 340px
where there is room, 294px at 500px tall, and the row picker flipping to
12–312px at 620px tall. The inner list carries no cap of its own any more — it
is the part that gives, and a fixed 240px there fought the menu's own bound and
won.
2026-08-03 15:30:32 +03:00
DumiFlex 7f4b7b93f1 fix(editors): seven from testing — menu colour, dismissal, column order, blank rows
**The tag menu lost its axis colours** when the picker became a shared
component: I rewrote its stylesheet instead of porting it, and the rules
consuming `--chip-hue` went with it. The hue is what makes the menu read as the
same colour clusters as the pills and chips everywhere else; a flat grey list
loses the only cue saying which axis a tag belongs to. Ported properly this
time.

**It was cut off at the bottom** whenever the list behind it was short — an
empty filter result leaves almost no page to sit on. Capped to the viewport,
with `min-height: 0` on the inner list so it shrinks and scrolls instead of
pushing the menu past the edge.

**It closed the moment the filter emptied the list.** Any layout change fired a
scroll, and the handler that keeps the teleported menu glued to its trigger
treated that as a reason to dismiss. It re-measures now, and only closes once
the trigger has actually left the viewport.

**Select's menu could not be scrolled by its scrollbar** — reported on the test
runner, present on every Select in the app. The menu is teleported to <body>,
so it is not inside the trigger's wrapper, and the outside-click guard checked
only that wrapper: a mousedown on the menu's own scrollbar counted as an
outside click and dismissed it. The wheel worked, which is why it read as "the
scrollbar is dead" rather than "the menu closes". The guard now excludes the
menu, as its comment always claimed it did.

Audited the rest, since this class of bug is easy to repeat: every other
teleported surface with an outside-click dismissal — IconPicker, ContextMenu,
the sub-category picker — already excludes its own panel. Select was the only
one that did not.

**Fixed Values had the grip before the checkbox** in the body and after it in
the header, so the two disagreed. Body now matches.

**Select-all skipped the null option.** `selectableOptions` still filtered it
out from when it could not be selected at all; selection drives moves, weight
and delete, all of which it takes part in. Tag actions still skip it — the
engine refuses tags there.

**Bulk add appended past the starter blanks.** A new wildcard or fixed-values
module opens with empty rows so the table is not a void; a first bulk add left
them sitting above everything just added, and they fail validation on save (an
option's value must be a non-empty string). An untouched blank is scaffolding
and is consumed by the incoming batch. One the user tagged or re-weighted is
NOT untouched — they were working on it — so only a fully default row
qualifies.
2026-08-03 15:15:22 +03:00
DumiFlex a3a0e3f95b fix(wildcard): widen the filter, stop the tag menu closing on its own scroll, keep tags off null
Three from testing the merged toolbar.

**The search box was a quarter of the width it should be.** Two nested flex
competitions, both lost the same way. Card's header puts a `.wp-spacer`
(`flex: 1`) between the title and the actions slot, and the filter block had
`flex: 1` too, so the two split the free space evenly; a far larger grow factor
gives the block effectively all of it. Inside the block a second `.wp-spacer`
was right-aligning the count and taking half of what was left — it is gone, and
the count now sits beside the tag button it describes. Measured 267px → 523px
of an 1122px header.

**The tag menu closed the moment you scrolled it.** The scroll listener that
keeps the teleported menu glued to its trigger fired for scrolls INSIDE the
menu too, so reaching for the wheel or the scrollbar dismissed it before you
could read past the first axis. Scrolls originating within a teleported picker
are now ignored: that is the user reading its own list. A scroll of the page
underneath still dismisses it, since the trigger has moved.

**Bulk edit was giving the null option tags.** It has no chips of its own — the
row renders a dash — so the tags went in invisibly, and the engine would have
refused the save: "null option must have no sub_categories"
(wildcard_handler.py). Its participation is governed solely by `exclude_null`,
so a tag on it could not affect anything even if it were allowed. Apply and
Remove now skip it. Set weight and Delete still include it, because it has a
weight and can be deleted — and it stays selectable, since selection also
drives the moves it took part in as of the previous commit.

So: no, null should not carry tags. The engine has always said so; the UI was
quietly disagreeing.
2026-08-03 14:50:34 +03:00
DumiFlex 890d868e16 feat(editors): one tag menu, one toolbar row, and Fixed Values gets the same treatment
Five follow-ups from testing the options filter.

**The tag menu is now one component.** The filter had grown its own dropdown
while the per-option picker had another — same job, two implementations, and
they would have drifted within a release. `TagPickerMenu.vue` is the picker,
extracted: teleported past the `overflow: auto` scrollers that clipped it,
search pinned above a scrolling list, axis headings, checkboxes. Both callers
use it.

**It closes when you click away.** The filter menu was teleported to <body> but
never registered with `closeAllMenus`, so it stayed open until its own button
was clicked again. It joins the outside-click and Escape dismissal the
per-option picker already had, and closes on scroll rather than hanging in
space once its trigger has moved.

**One row, not two.** Bulk edit / Add null / Add option sat on one line and the
filter on another, which made the header taller than the first option in the
list. They all act on the same table; they belong together. The filter takes
the flexible width, the buttons keep their intrinsic size.

**The null option is a row like any other.** It could not be selected in bulk
edit and could not be reordered, because `hoistNullFirst` re-pinned it to index
0 on every save and any move would have silently undone itself. That pin was
never needed: the engine locates the null option by the `is_null` FLAG and
never by position — "the flag is the source of truth", wildcard_handler.py, and
`resolve` filters with `[o for o in options if not o.get("is_null")]`. The
function is gone, and the row now has a checkbox and a grip.

**Fixed Values gets the filter and the reorder.** No tag menu — those rows
carry no tags — so the text box searches BOTH halves, the binding and the
content, because you look for either and matching one would send you back to
scrolling half the time. Grip column, Alt+↑/↓, and Top / Bottom on the
selection toolbar, with drag disabled under a filter for the same reason as in
the options list.

The per-option picker's test hooks changed shape with the extraction —
`opt-tag-{id}-toggle-{tag}` rather than `opt-tag-toggle-{id}-{tag}` — since the
component namespaces every hook uniformly off one prefix.
2026-08-03 13:25:42 +03:00
DumiFlex 530714e62b feat(wildcard): filter the options list, and reorder it
A wildcard here carries 133 options. Until now the only way to reach one was
to scroll and read, and the only way to order them was to have added them in
the right sequence.

**Filter.** Text over the value plus a tag multi-select, pinned in the sticky
subheader so it survives the scroll. Text and tags AND together, and multiple
tags AND with each other — adding a second constraint should narrow the result,
and OR would widen it, which reads as the control being broken. The count sits
idle ("133 options") until a filter is on, because "133 of 133" reports a
success nobody asked for. The empty state says all 133 are still there: an
empty table in a list you can also delete rows from is an alarming thing to be
shown.

Rows keep their ORIGINAL index through the filter. Every row action addresses
its option by position — `removeOption(i)`, the `wc-opt-row-${i}` hooks — so
renumbering them under a filter would delete the wrong row.

**Folded tags get promoted.** A row shows four tags and folds the rest behind
`+N`. Filter by a tag sitting in that remainder and the row appeared with no
visible reason for being there, the evidence hidden behind the very pill saying
there was more. Matched tags are now promoted into the visible slots — tags are
a set, so their order carries no meaning and promoting one is free. Auto-
expanding instead would reflow every matching row at once, a lot of movement
for a small clarification. When matches remain folded even so, the pill itself
is tinted and says why.

**Reorder,** three ways, because no single one covers the range:

- A grip in its own column, hover-revealed. The only pointer affordance the
  value cell's contenteditable does not poison — a press there is a text
  selection, and the two gestures are indistinguishable at mousedown.
- Alt+↑/↓ on the focused grip. The only one that stays usable at 133 rows,
  where a drag becomes an auto-scroll fight.
- Bulk edit ▸ Top / Bottom / Move here. Select, then move as a block.

**Drag is disabled while filtered; the bulk moves are not.** Dropping row 3
above row 47 says nothing about the 43 hidden rows between them, so there is no
honest position to compute. The bulk moves have no such problem because their
destination is absolute — "top" means the top of the whole list, "here" means
beside one specific visible row. So search ▸ tick ▸ move is exactly the
workflow that stays well defined under a filter.

Two behaviours worth stating, both asserted:

- A scattered selection collapses into one contiguous block in the relative
  order it already had. There is no other coherent answer, but it surprises the
  first time, so the result is reported rather than discovered.
- The null option never moves and is never a landing point. `hoistNullFirst`
  re-pins it to index 0 on every save, so any move it joined would silently
  undo itself.

Ordering and set logic live in `utils/option-list-ops.ts`, out of the 2.4k-line
editor and directly assertable.
2026-08-02 20:54:43 +03:00
DumiFlex dd1d5d429f feat(editors): guard bulk-add against the page's own Save, keep empty tag groups
Two reported losses of work.

**Bulk add was easy to throw away.** Users reach for the PAGE's Save and Cancel
instead of the panel's own "Add options" / Cancel — the page pair is larger and
in the place muscle memory expects — and the typed batch went with it. Two
guards, and deliberately no new dialog for the common case:

- Save is greyed while the panel is open, so the only Save-shaped control that
  responds is the panel's own commit. The button carries a tooltip saying why;
  a greyed control with no stated reason reads as a broken one, and the reason
  is exactly what the user needs in order to clear it.
- Un-added text now counts as unsaved work, so the route guard that already
  covers Cancel, the back link and every other navigation picks it up for free.
  Only the wording changes: "Discard un-added options?" rather than the generic
  unsaved-changes copy.

Both editors that have a bulk-add panel — wildcard options and fixed values.
Constraint and derivation have bulk EDIT but no bulk add, so nothing to guard.

**Empty tag groups vanished on save.** A "+ Group" box created and not yet
filled was dropped, which reads as the editor deleting work the user did on
purpose. Dropping stays the default, because it is also what clears up boxes
made by accident, but the choice is now theirs: Settings › Editing › Keep empty
tag groups.

Stored as a preference rather than a payload field. An empty axis is already
representable as `{axis: []}`, a shape the engine's validator accepts — the
loop over members simply does not run — so keeping one needs no new field, no
validator, no migrator and no schema bump. What varies per user is the
DECISION; the RESULT is recorded in the payload like any other edit.

Note on the ref declaration: the guard's predicate runs during setup, so
`bulkPending` is declared above it rather than beside the panel state it
belongs with. Putting it in the obvious place threw "Cannot access
'bulkPending' before initialization" on every editor mount.
2026-08-02 19:53:28 +03:00
DumiFlex 1f07d6eb13 fix(chips): let a selection paint over a chip instead of skipping it
`.wp-refchip` carried `user-select: none`, which excludes an element from
selection RENDERING. Ctrl+A, or dragging across a value, highlighted the text
on either side and left the chip looking untouched — so the selection appeared
to stop at the chip even though it never did. Confirmed in Firefox 153:
`Selection.containsNode` reported the chip inside the selection the whole
time, and the selected string always contained its label. Only the paint was
missing, which made a correct selection look like a broken one.

Chips are atomic for EDITING because they are `contenteditable="false"`, not
because of `user-select`, so dropping it costs nothing there. Added an explicit
`::selection` colour as well: a chip carries its own tint, and the default
highlight washed it out.

Re-measured the whole interaction matrix afterwards, since making chips
selectable could plausibly change how edits fall across them — Ctrl+A followed
by Backspace, Delete, Shift+Delete, cut, paste and typing; undo, redo, bold,
italic, underline, Enter, double- and triple-click, Home/End deletes and
Ctrl+Backspace; each with and without a chip in the field. 40 cases, all
intact, no formatting elements injected, no page errors, and clicking a chip
still opens its filter picker.

The rule is asserted against the stylesheet source rather than a computed
style: it lives in a scoped <style> block, which jsdom does not apply, so a
computed-style check would have passed no matter what the rule said.
2026-08-02 19:01:12 +03:00
DumiFlex e5b3edba43 fix(editor): take cut and copy away from the browser
Reported: cutting an option's text and pasting it into another leaves the
field broken, after which Cancel does nothing and sometimes links stop working
too. Reproduced in Firefox 153 and measured.

A Ctrl+A selection is anchored on the HOST element, not inside a text span. Cut
was the one clipboard verb with no handler, so the browser performed the
deletion itself — and that deletion took the `wp-rt__text` spans with it, the
very elements Vue's vnodes point at. Measured directly after the cut: zero atom
spans left, one child node, the value a stray newline. The field still accepted
typing, but nothing chipped and nothing coloured, because there was no atom
span left to hold any of it, and the vdom now referenced detached nodes. That
is the state a later patch throws `insertBefore: Child to insert before is not
a child of this node` from — the DOMException reported separately.

Cut now runs through `applyAtoms` exactly as paste already did, so the
structure stays Vue's.

Copy was quietly lossy in the same way. A ref chip's DOM text is its display
label, so the browser's own copy turned `red @{955bb6fa} tail` into `red  tail`
and the ref vanished on paste — measured, every time. Copy now puts the SOURCE
text on the clipboard, and a chip survives the round trip.

Third, a general repair: any edit made against a host-anchored selection can
delete the atom spans, not merely their text — Ctrl+A then typing a character
did it too. `applyAtoms` already knows how to recover (it swaps the host
element when the DOM has gone stale) but nothing reached it from the typing
path, which deliberately avoids re-applying atoms to keep the caret steady.
`onHostInput` now re-applies when, and only when, the structure is actually
gone.

Measured across the full matrix — Ctrl+A followed by Backspace, Delete, cut,
typing, and paste, each with and without a chip in the field. Before: two of
ten cases left the field with no atom spans and one silently dropped a chip.
After: ten of ten intact, and the chip round-trips.

Not a bug, for the record: Cancel refusing to navigate while there are unsaved
edits is the route guard working — it raises "Discard unsaved changes?" and
waits. It only reads as a dead button when the render loop has already thrown,
which is what the cut corruption above led to.
2026-08-02 18:40:38 +03:00
DumiFlex 4b53aca434 fix(editor): stop the stranded-caret rescue from typing in the wrong place
Two user-reported Firefox bugs, both in the same rescue path. The host parks
the selection on ITSELF rather than inside a `wp-rt__text` span whenever the
caret is at the very start of the field — pressing Home, or clicking before
the first character. Firefox does this most readily, which is why both reports
came from there. `onHostBeforeInput` redirects such a caret into a span before
the browser inserts, and it was redirecting to the wrong place twice over.

**Typing at the start went to the end.** With no chip adjacent, the fallback
picked the LAST span and put the caret at the END of its text. Typing `{` in
front of `skirt` produced `skirt{`. On the host root the offset is a CHILD
INDEX, so it already says where the caret is; the fallback now scans forward
from it for a span and lands at that span's START, falling back to the end of
the preceding span only when there is nothing after.

**Typing over a selection did not replace it.** The rescue ran for ranges as
well as carets and unconditionally collapsed the selection, so the browser
never deleted what was selected — select-all then typing one letter over
`yellow` left `yellowy` instead of `y`. A range is exactly the case the
browser already handles correctly, so the rescue now returns early for one.
This is the mangling behind the `yyellow` in the first report's video.

Verified against Firefox 153.0 — the reported version — driving the real
browser: `skirt{` → `{skirt`, `yellowy` → `y`. Regression tests go through the
DOM Selection API rather than component internals, since the bugs live in the
interaction between the browser's own insertion and our redirect; both were
confirmed to fail against the previous code before being kept.

Not reproduced, and NOT fixed here: the report also mentions Ctrl+A + Backspace
failing to clear the field and a paste doing nothing. Both work in Firefox 153
before and after this change. An early test of mine appeared to show the paste
failing, but that was the synthetic ClipboardEvent delivering no data — a real
Ctrl+C/Ctrl+V round trip replaces the selection correctly. The `yyellow` state
in the video is explained by the selection bug above, so these may well be the
same defect seen from the other side; if it recurs after this lands, it needs a
fresh repro.
2026-08-02 10:42:23 +03:00
DumiFlex 6981314fb5 fix(producers): count writers, not write statements, and carry the count across nodes
`overrides N` was wrong in both directions.

**Inflated.** Several call sites fan out — a derivation writes once per branch,
a fixed_values once per row — because which branch fires is not knowable
statically and every reachable `target_var` has to appear in the var list.
Counting each of those as an override meant a rule with twelve branches all
targeting `$env_fx` reported "overrides 11", when at most ONE branch fires.
Worse, it reported it when nothing upstream wrote `$env_fx` at all: the module
that CREATES a variable was described as overriding eleven writes that never
existed.

`shadowed` now counts distinct PRODUCERS. Identity is passed in by the caller
rather than derived from the producer record, because `moduleId` is the library
uuid — the same module dropped into two Context nodes shares it while being two
genuinely competing writers. Callers key on the node plus the module's
per-instance `_uid`. Injector rows and loop vars have no module and fall back to
the node plus kind, so two rows of one injector binding the same name read as a
single writer; that is the honest summary, since the injector as a whole is what
binds the name.

**Lost.** The sibling producer map added for same-node attribution counted only
siblings and started from zero, and the merge was a plain object spread — which
replaced the upstream entry outright, taking its count with it. With `$outfit`
written by an upstream wildcard, rewritten by an upstream derivation, then
rewritten again by a module in this node, the row showed no badge at all. A
sibling that displaces an upstream writer has overridden that writer plus
everything it had already overridden, and the merge now says so.

Also: `WP_DEBUG_MAPS=1 pnpm build:extension` links the sourcemaps. They are
built either way, but `sourcemap: "hidden"` writes no `sourceMappingURL`
comment, so browsers never load them and every canvas stack trace arrives as
frame after frame of minified Vue runtime naming no component of ours. Off by
default — the comment ships to every install and the maps are large.
2026-08-02 08:36:32 +03:00
DumiFlex b3c4270f6c feat(autocomplete): mark node-supplied pools on the rows, give injector vars an icon
The node-vs-library marker landed on the ref chip and the filter header, but
not on the suggestion rows — which is the one place it is actually load-bearing.
By the time the chip carries it you have already chosen; the point of the mark
is to tell you WHICH of two same-named wildcards you are about to reference,
and whether it will resolve against this node's snapshot or the library row.
`@` rows carry it now, styled exactly like the filter header's so the fact
reads the same in all three places.

Injected `$var`s had no icon. `collectUpstreamProducers` reports WP_ContextInjector
rows as `kind: "injector"`, and `KIND_ICON_MAP` had no entry for it, so every
one of them fell through to the generic `pi-circle` and rendered as an empty
ring next to properly-marked siblings. Added `pi-sign-in` — the arrow-into-a-
door mark, matching "inject a value into the context". `--wp-kind-injector`
already existed in the canvas palette, so the tint was there waiting for a
glyph to put it on.

Two guards on the icon map itself, which had none:

- Every entry must name a glyph the installed PrimeIcons actually ships. A
  missing glyph renders an empty ring, which reads as "this kind has no icon"
  rather than as a mistake, so nobody reports it — exactly how `brush` shipped
  broken in the icon picker. The stylesheet is the authority, parsed rather
  than mirrored in a list that would go stale on the next upgrade.
- Every kind a canvas producer can emit must resolve to something other than
  the fallback circle. That is the assertion that would have caught `injector`.
2026-08-02 08:16:20 +03:00
DumiFlex 54b525cfa0 feat(canvas): attribute $vars written in the same node, and mark node pools
Three things the canvas could not say that the SPA could.

**$var suggestions were bare.** `collectUpstreamProducers` walks the node
chain and deliberately excludes the point-of-view node, so it can only
attribute vars produced further upstream. In the normal shape — one Context
node carrying most of the modules — nearly every name in the dropdown had no
writer at all, and the rows rendered as a bare list while the SPA showed a
full attribution line for the same names. It looked like the canvas was not
walking the graph; it was, and then discarding the half that mattered.

The node's own modules now produce a matching map, built in the same pass as
the sibling name list so the two cannot disagree about which module owns a
name. Siblings override upstream writers, which is what the engine does, so
the row names the module whose value actually survives. Their tail reads
"this node" rather than a node label, because every sibling would give the
same answer.

Separately, the combine template editor never received producers at all —
`ModuleEditModal` forwarded them only to the derivation modal — so that one
was bare regardless of where the vars came from. Threaded through.

**Nested refs never said whose pool they use.** A ref resolves against this
node's snapshot of a wildcard when it has one, and that snapshot can hold
different options from the library row of the same uuid. Until now the only
way to discover that was to open the hover card. The chip carries a small
marker when the node supplies the pool, and the filter panel's header carries
the matching one, so "this is the node's copy — these counts describe it"
reads the same in both places. The library case stays unmarked: it is the
default, and marking both turns a signal into decoration. Origin is decided by
presence in the node's pool map, which is exactly how `resolvePoolFor` already
picks the winner for the hover card.

**One more unbounded preview.** A wildcard option's value in the pool list had
no line cap, so a paragraph-length option grew taller than the rest of the
pool put together and pushed every other option off the panel. Clamped to
three lines with the full value on hover — with `overflow: hidden`, without
which `-webkit-line-clamp` silently does nothing.
2026-08-02 08:04:51 +03:00
DumiFlex 06bb1b39d7 fix(wildcard-editor): unclip the tag menu, contain its scroll, fold the axes
Four things, all in the way of editing a wildcard with a real tag vocabulary.

Escape threw away the edit. The editor pages run a window-level Escape
shortcut that cancels the edit and routes back to the list. It opts out when
focus sits in an `input, textarea, [contenteditable]` — which covers the filter
panel's expression field but NOT its tag buttons, so pressing Escape after
clicking a tag navigated off the page. That is why it only happened sometimes.
The picker now takes Escape in the capture phase and stops it there, so while
it is open the key cannot reach anything else.

The per-option tag menu was clipped. It was `position: absolute` inside
`.wp-editor__body`, an `overflow: auto` scroller, so rows past that container's
edge were simply cut — measured 34px lost near the foot of the page. It is
teleported to <body> now and positioned from the trigger's rect, which is what
every other popover here already does. It re-anchors on scroll and resize
rather than closing: closing was the first attempt and was wrong, because the
browser scrolls a partly-visible button into view as part of clicking it, so
opening a menu near either edge closed it in the same gesture. Once the trigger
leaves the viewport entirely there is nothing to anchor to, and it does close.

Scrolling it moved the page. The menu's list now carries
`overscroll-behavior: contain`, so reaching either end stops there instead of
handing the leftover delta to the editor behind it. Same fix applied to the
filter panel's tag list. The cap and the scrolling also moved off the menu and
onto the list inside it, so the new search box stays pinned while the tags
scroll under it.

Everything is searchable, and the axes fold. 30+ checkboxes in a menu is
not something you scan; the picker gains type-to-filter, and the sub-categories
editor gains a per-axis fold so you can shut the four axes you are not working
on. A folded axis still shows its tag count, so folding never hides the fact
that something is there. The caret is its own button rather than the whole
header, because the header also holds the rename input and making the row the
toggle would fold the group every time you clicked in to edit the name.

The option-tag tests now query the document: teleporting the menu puts it
outside the wrapper's element tree, so `w.get()` cannot reach it.
2026-08-02 07:38:37 +03:00
DumiFlex 77fc32d760 feat(filter): rebuild the sub-category panel to the v3 design
The panel had an expression field, a reads-as line and a flat chip palette.
Authoring anything real with it meant holding the wildcard's tag vocabulary in
your head, and the two states that matter most were invisible.

The expression is now syntax-coloured: terms in accent, operators in blue,
parens dim, and an undeclared word underlined in red AS you type rather than
after the validator finishes the sentence. A textarea cannot colour its own
text, so the paint is a mirror element beneath a textarea whose glyphs are
transparent; every metric that decides where a glyph lands is declared once
and inherited by both layers, because if they disagree the colours slide off
the characters. The field also wraps, grows and drags — a single line clipped
anything past ~40 characters, and a real expression is longer than that.

The palette is grouped by axis, each foldable, with a search over the tags.
A folded axis still reports "2 used" / "1 negated", so folding never hides
state. Chips take their state from the parsed AST — lit when included, struck
when negated, dashed when the wildcard declares a tag that no option carries —
so the palette stays a VIEW of the expression rather than a second source of
truth for it. Only the tag list scrolls; putting the scrollbar on the popover
would push the expression and Apply out of view exactly when a long list makes
them most useful.

Two states that were previously silent:

- Valid but matching nothing. `skin-tone and pastel` is well-formed over
  declared tags; it just happens no option carries both. That applied silently
  and you found out at run time with the ref resolving to nothing.
- An unknown term now offers the nearest declared tag, and applying it
  rewrites that word in place rather than re-serialising the expression, so a
  one-character typo fix does not reformat the line you are writing.

The count now includes the null option and drops it when Exclude null is
ticked. `optionTagSets` carries only non-null options, so the popover
advertised "132 options" while this panel counted against 131, and ticking the
flag changed nothing on screen.

Also: a header naming the wildcard being filtered — a breadcrumb back to the
suggestions on the insert flow, a plain title on chip-edit — with Escape
matching what that header promises. The whole Exclude null row is a click
target, not just the 13px box.

Popover rows gain the same treatment: the glyph sits in a tile tinted with its
OWN kind's colour, since a `$var` can be written by a fixed_values or a combine
as easily as by a wildcard; facts are discrete tiles; and the producer line
keeps the module name separate so it can be highlighted, because that name is
the identifying half of the sentence.
2026-08-02 07:12:21 +03:00
DumiFlex d221ea479f feat(autocomplete): give the @ and $ popover rows an identity
Both popovers rendered one line of text per hit — a name, and for `@` a
trailing "12 opts · a1b2c3d4". That distinguishes entries whose names already
differ, and nothing at all when they don't, which is precisely the case that
sends someone to the popover in the first place. A library here has two
wildcards called `act_plan`; the old rows were byte-identical.

Rows are now built from what the thing actually is:

- `@` rows carry the wildcard's option count, its axes and its tag count,
  with the uuid kept as the last-resort tiebreaker for entries that match on
  every other fact. A funnel marks rows where Tab opens a filter, shown on
  the active row only — on every row it is a column of noise, and the
  affordance is unreachable for rows you are not on.
- `$` rows name the writer. The canvas walked a graph so it reads "written by
  Style FX · ember-marten" and can honestly say a write "overrides 2"; the
  library has no execution order, so the same count reads "2 others bind
  this" instead. That distinction is resolved in one place, so the popover
  and RefChip's hover card cannot drift into telling different stories about
  the same variable.

The header now reports how many entries MATCHED, and says "· showing 50" when
the row cap hid the rest. It first reported `acRows.length`, which is the
post-cap figure — "50 matches" for a query that hit 89. A silent truncation is
how you conclude the wildcard you are looking for does not exist.

Row models live in `utils/suggestion-rows.ts`: pure data-to-data, out of the
2.8k-line editor that hosts them, and directly assertable.

Bundle budget 468 -> 490 KB. The previous bump left 179 bytes free, so every
change since has had to argue with the gate; this one is sized to also cover
the filter panel that follows.
2026-08-02 06:17:35 +03:00
DumiFlex b09400ecd6 feat(test-runner): make the module picker survive duplicate names
The picker built its rows as `{ value: id, label: name }`, so a library
holding two pools both called "Lighting" — which is what you get, because it
is the obvious name for the indoor one and the outdoor one — rendered two
identical lines with no way to tell which was which. The fields that would
have distinguished them were projected away one computed earlier, where
`filteredItems` narrowed whole rows down to `{ id, name }`.

Rows now stay whole, and each option carries its category, its payload
summary, and — only when the bare name is genuinely ambiguous — a short uuid,
appended last so it reads as a tiebreaker rather than as the row's identity.
The detail comes from `entitySubtitle`, the same function the Import and
Export pickers use, so a row reads the same wherever it is offered.

The builder lives in `utils/entity-select-options.ts` rather than inline in
the view: it is pure data-to-data, and when a uuid appears is worth asserting
directly instead of through a mounted component.

Two fixes in Select itself, both about the same failure:

- `meta` now repeats in the CLOSED trigger. Disambiguating rows only while
  the menu is open solves half the problem — pick one of five identical
  "Outfit" rows and the trigger collapses back to "Outfit", so the choice you
  just made carefully becomes unverifiable again.
- The icon/dot gutter is reserved for every row in a list where any row has
  one. Mixed lists rendered a ragged left edge, and that indent reads as a
  hierarchy that does not exist. Width is measured off the widest marker
  actually present, so a dots-only list does not pay for icon width.

Categories are fetched OUTSIDE the `Promise.all` that loads modules and
bundles. Joining them would let a failure fetching what is purely icon-and-
colour reject the whole batch and leave the runner with an empty module list.
2026-08-02 06:00:03 +03:00
DumiFlex 685d6593fd feat(test-runner): band the histogram rows so a row reads across its columns
The four histograms put a label, a bar and a count on one row, several rows
deep, with nothing between them. Rows are also variable height — a clamped
label is one line or three — so there is no steady rhythm to help the eye
carry a label across to its own count.

Banding, not highlighting. Two details decide which one it reads as:

- The container's `gap` is gone. With a gap, tinted rows cannot touch their
  neighbours, so each one floats as a discrete filled block and the fill
  reads as "this row is selected". The spacing now lives inside each row as
  padding, so the stripes are contiguous. This is the change that matters;
  contrast alone cannot fix a gapped stripe.
- No border-radius, and a negative inline margin matching the card body's
  14px so each stripe bleeds to the inner edge. A rounded, inset fill is a
  pill, and a pill on a row is the universal shape of selection.

The tint is `color-mix` against `--wp-text`, so it inverts with the theme
for free — verified rendered in both wp-theme-dark and wp-theme-light.
2026-08-02 05:41:21 +03:00
DumiFlex 4fc3cd84d0 fix(ui): bound the text that could run unchecked
Swept the project for user text rendered with no bound on its size, after the
Test Runner histogram turned a paragraph-length option value into a forty-line
label that pushed every other bar off the screen.

Static analysis flagged 168 interpolation sites, but most are fixed labels or
sit inside a parent that already clips — the useful signal was 54 genuinely
unbounded free-text renders, of which three are reachable today:

  * TestRunner `.wp-hist__template` — used by three of the four histogram
    types; only the fourth had ever been given `wp-truncate`.
  * Templates `.wp-tpl-full` — `max-width` but no height bound, so one long
    template pushed every row below it away.
  * InjectorRow `.wp-inj-summary__template` — a collapsed one-line summary
    that a long template silently stretched.

Each gets the treatment its ROLE calls for rather than one blanket rule:

  * histogram label — a preview beside a bar, so a two-line clamp (three for
    bundle rows, which show several modules at once) plus `title` for the rest.
    One line loses the difference between near-identical option values.
  * template detail pane — opened deliberately to READ it, so cap and scroll;
    clamping would defeat the point of expanding.
  * injector summary — one line is the whole contract, so a hard ellipsis.

Caught while verifying the BUILT css, not the source: a later rule reset
`.wp-hist--bundle .wp-hist__template` to `overflow: visible`, which silently
disables `-webkit-line-clamp` — the clamp would have shipped doing nothing.
That rule exists to let bundle labels wrap, which `white-space: normal` already
handles, so it keeps the wrapping and drops the overflow reset.
2026-08-02 05:05:58 +03:00
DumiFlex 88e75ac60c fix(inputs): draw the grip as a corner triangle, and stop fields collapsing to nothing
Two follow-ups to the grip.

**Shape.** The replacement grip was a filled square of hatching, which read as
a button. Clipping it to a bottom-right triangle puts the stripes parallel to
the hypotenuse — the shape the browser's own resizer draws, and the one users
already recognise as "drag me".

**Floor.** The canvas fixed-values rows never declared a `min-height`, so the
computed value was `auto`, which `parseFloat` turns into NaN and the old
`|| 0` turned into a floor of zero. With the native resizer that was masked by
the browser's intrinsic minimum; taking it over exposed it, and the rows could
be dragged away to a sliver with no way to see what you were editing.

Fixed at both levels, because they answer different questions. `.row__value`
now declares `min-height: 28px` — CSS stays authoritative about what a given
field's floor should be. `useGrowableField` additionally falls back to a hard
28px whenever CSS declares nothing usable, so a future caller that forgets can
not reach zero either.
2026-08-02 04:49:06 +03:00
DumiFlex 7bf39d9337 fix(inputs): replace the native resizer, which cannot be clamped
Your detail — "it gets stuck when the bottom edge touches the first character"
— was the box reaching one line, i.e. its `min-height`. Same fight as the
`max-height` cap fixed in the previous commit, at the other end, and the reason
three earlier attempts all missed it.

The native `resize` computes `startHeight + (pointerY - startY)` from an origin
it captures at pointerdown, and clamps to nothing. Drag past a limit and that
sum keeps running while the rendered box stays pinned, so dragging back moves
nothing until the invisible total walks all the way home.

Measured, collapsing past a 34px floor then expanding:

  native resize, floor 34px ........ 13 / 23 frames dead
  native resize, floor lifted to 0 . 10 / 23   (intrinsic minimum remains)
  rewriting style.height each frame  13 / 23   (browser ignores it)
  own grip, clamped ABSOLUTE math .. 13 / 23   (input already accumulated)
  own grip, incremental delta ......  0 / 23

Nothing outside the resizer can fix it: writes to `style.height` are discarded
because the browser recomputes from its own origin, and clamping the result
does not help when the accumulated input is what overshot. So the native
resizer is gone, replaced by a grip that applies each move's DELTA to the
current height and clamps to the CSS floor. No history, nothing to unwind — the
first pixel back off a limit moves the box.

Verified on the live SPA field with the reported sequence (scroll to the end,
over-collapse, expand): 0 / 19 dead frames.

The grip sits in the corner the overflow fade already leaves clear, and is
drawn like the native one it replaces. Also removes the `clampInlineHeight`
helper from the previous commit — disproven by the table above, and its
docblock claimed a fix it did not deliver.
2026-08-02 04:27:06 +03:00
DumiFlex ea188af729 fix(inputs): let a manual drag override the height cap
Measured this one instead of guessing at it. A real Playwright drag on a
capped field versus an uncapped one, sampling height against pointer position:

  capped    down: 60 70 … 190 192 192 192 192 192 192 192 192 192 192 192
            up:   192 192 192 192 192 192 192 192 192 192 192 190 180 …
            frozen while dragging up: 10 / 25 frames
  uncapped  frozen: 0 / 25

`max-height` and `resize` fight each other. Drag past the cap and the inline
height keeps climbing while the rendered box stays pinned; drag back and
nothing moves until the inline value falls under the cap again. That dead zone
is the reported "gets stuck, then starts working", and it bites exactly AT the
cap — which is also where the overflow fade appears, which is why the fade kept
looking like the culprit.

It was never the fade, the pointer events, or the ResizeObserver. My three
previous attempts all treated symptoms.

The cap exists to stop AUTO-grow eating the screen; a deliberate drag is the
user overriding that, so it now yields. On a pointerdown in the grip zone the
current rendered height is pinned and `max-height` is dropped — pinning first
because removing the cap from an `auto`-height box would otherwise let it leap
to full content height the moment the handle is touched. Grip-zone detection
keeps a plain click in the text from pinning the box and killing auto-grow.

Same harness re-run against the fix: 0 / 25 frozen frames, tracking identical
to the uncapped control.

Also restores live grip-follow. Deferring it to pointerup was part of the
previous wrong theory and just made the field slide off-screen for the whole
drag; scrolling by the overshoot moves the element up by exactly that much,
landing its bottom edge where the pointer already is.
2026-08-02 03:36:09 +03:00
DumiFlex 385e7b7778 fix(inputs): keep the resize observer out of the drag's way
The drag still seized the moment the fade appeared, on both targets. The fade
was the tell, not the cause: crossing the overflow threshold is simply when the
observer callback starts doing work, and the work was the problem.

Two things ran synchronously inside the ResizeObserver callback:

  * `updateOverflowHint` read `scrollHeight` / `clientHeight` and wrote a
    reactive ref, so every frame of a resize dirtied layout inside the very
    callback the browser uses to report layout. That is the ResizeObserver-loop
    case: the engine throttles and skips delivery for a frame, which the user
    feels as the drag locking up until they release and re-grab.
  * `followGrip` scrolled the container mid-drag, sliding the element out from
    under a pointer the browser anchors its resize tracking to. My own fix from
    the previous commit, adding a second stall on the way out.

The callback now only schedules; all measuring, state writes and scrolling
happen in the following animation frame. Grip-follow additionally waits for
`pointerup` — the field still ends up on screen after a drag past the fold,
without anything moving while the handle is held. Listeners sit on `window`,
since a resize drag routinely ends with the pointer outside the field.

The regression test asserts the invariant that actually matters rather than the
symptom: with rAF stubbed out, invoking the observer callback must read layout
zero times.
2026-08-02 03:20:34 +03:00
DumiFlex e5cc122341 fix(inputs): auto-grow must yield to the drag handle, and the grip must stay reachable
Three defects in the same field, two of which never reached the canvas because
I fixed them in the wrong component. `RichTextInput` and the fixed-values
`ValueRow` had each grown their own copy of "capped, resizable, auto-growing
text field", and the copies had diverged.

**Auto-grow stomped the manual resize.** `ValueRow.autosizeValue` wrote
`height = scrollHeight` on every input AND on every external value change, so a
box the user had dragged smaller sprang straight back open on the next
keystroke. That is the reported "hold it to collapse, it gets stuck, then
starts working" — the drag was working, the auto-fit was undoing it.
`useGrowableField` now records that the user has taken control and stops
auto-fitting from that point on, distinguishing its own writes from a drag with
an `autoDriven` guard so the first fit does not lock the field immediately.

**The fade hid the grip.** The bottom hint band is exactly as tall as the
native resize handle and sat right on top of it, so the handle was invisible:
aim, miss, re-grab. It is `pointer-events: none`, so it never blocked the drag
— it hid the target. The band now stops 16px short of the right edge. Fixed in
both components; previously only `RichTextInput` got it, which is why the
canvas fixed-values modal still looked wrong after a rebuild.

**Nothing followed the grip off-screen.** Dragging a field taller than the
space below it moves the box, not the pointer, so the handle slides out of view
and the drag goes blind. The shared helper scrolls by exactly the overshoot,
pinning the bottom edge to the viewport. Walks computed `overflow-y` for the
real scroller rather than assuming the page scrolls — on the canvas it does
not, and `window.scrollBy` would have been a no-op. Deliberately not
`scrollIntoView`, which this project bans for moving every ancestor.

Extracted rather than fixed twice: a third copy was about to be written for the
sub-category filter's expression box.
2026-08-02 03:05:55 +03:00
DumiFlex 09227b216c feat(filter): derive chip state from the parsed expression
Groundwork for the sub-category picker redesign. The palette's chips need to
show what the expression currently does with each tag — and that has to be read
from the parsed AST, not from what the user clicked, or the chips desync the
moment an expression is typed, pasted or hand-edited. The palette is a view of
the expression, never a second source of truth for it.

`collectTermPolarity` walks the AST tracking `not` parity, so `not (warm or
cool)` marks both negative and `not (not warm)` comes back positive. A tag
appearing both ways lets positive win: the chip reads as participating, and the
negation stays visible in the expression text.

`livingTags` separates DECLARED tags from tags any option actually carries.
A declared tag no option uses is offerable and guaranteed to match nothing —
the exact shape of the nested-ref bug — so it gets its own `dead` state rather
than looking selectable. It does NOT outrank `in`/`negated` though: once a tag
is in the expression the chip shows that, because the zero-match warning
already covers the consequence and two alarms for one mistake is noise.

`nearestTerm` backs the validator's did-you-mean with a length-scaled edit
budget — `colde`→`cold` yes, `magenta`→anything no. A wrong guess is worse
than no guess.
2026-08-02 02:28:57 +03:00
DumiFlex 8f388adc55 fix(inputs): stop a scrollable field handing its scroll to the page
Scrolling inside a capped, resizable field and reaching either end let the
remaining delta chain to the ancestor, so the modal — or the whole editor page
— lurched while the pointer was still over the field. Worst at the TOP edge,
where a small upward flick past the first line jumps the page.

`overscroll-behavior: contain` on every field that both caps its height and
scrolls: RichTextInput's `--wrap` and `--multi` hosts plus the nine other
resizable textareas across the assembler, cleaner, combine, fixed-values,
push-to-library, injector, bulk-add and import surfaces.

Applied per-rule rather than as one `textarea { … }` global: an unprefixed
element selector is exactly what `pnpm check:css-isolation` blocks, and on the
canvas it would reach into ComfyUI's own DOM. The pattern already exists in
five places (SelectionToolbar, UnmetDepsDialog, PickerSection…) — this brings
the input surfaces in line with it.
2026-08-02 02:22:10 +03:00
DumiFlex 2f08c138ac feat(refs): one pool for the picker, and a debug warning that explains itself
Two halves of the same gap, left over from the nested-ref fix.

**The sub-category picker offered the wrong pool's tags.** Its checkbox list
came from `buildWildcardRefData(catalog)` — a library fetch — while the engine
filters the ref against THIS node's snapshot whenever the uuid is a module
here. On a drifted snapshot the picker listed a tag no option in the running
pool carries: pick it, and the ref resolves to nothing, with the checkbox list
itself being the thing that misled you.

`overlayContextPools` applies the same precedence `resolvePoolFor` already
gives the hover card, so card, picker and engine now describe one pool.
`ContextPool` grew the declared vocabulary (`subCategories`, `tagGroups`,
`hasNull`) the picker needs — distinct from `tagSets`, which is what options
actually carry; a declared tag no option uses is offerable but matches
nothing. `uuidToName` is deliberately NOT overlaid: it holds the variable
binding a chip labels itself with, not a display name.

Only the derivation modal needed it. The constraint modal already builds from
node modules, and the SPA editors have no node, where the overlay is a no-op.

**"Matched no options" told you nothing.** The engine emitted
`ref_subcategory_empty_pool` all along, but `WARNING_LABELS` had no entry, so
Debug rendered the raw slug — and even read, the message could not distinguish
a wrong filter from a right filter aimed at a stale copy. That ambiguity is
exactly what made this bug a hunt.

`_stamp_pool_provenance` records, per catalog entry, whether the pool came
from this node or the library, plus the library's option count when a node
copy has drifted. The warning now reads:

    @{05c54db9:test} matched no options — searched this node's copy
    (1 option); the library now has 2

with `pool_origin`, `pool_total` and the pool's `available_sub_categories` in
`detail` — the tags it DOES offer being the whole explanation. Labels added
for the two nested-ref warnings and `ref_out_of_surface`.

Provenance is runtime-only: `__wp_catalog__` is stripped at the socket and the
workflow-embedded snapshot is built elsewhere, so neither key reaches saved
JSON. The extra reads are one per embedded wildcard on an already-open
connection, and the stamping never raises — diagnostic garnish must not take a
graph run down.
2026-08-02 01:25:28 +03:00
DumiFlex 6cbff32827 feat(refs): say when the pool lives in another node, not just "library"
Follow-up to the pool-source card. Naming the library as the source is honest
but leaves the obvious question unanswered: the pool is right there on the
canvas, in a node the user can see, so why isn't the ref using it?

Because a `@{}` ref only ever sees its OWN node's modules plus the library —
`__wp_catalog__` is rebuilt per node and is not among the cross-node internals,
so a catalog never crosses the socket. That is the exact opposite of how
`$vars` behave, which is what makes it a trap: move a pool one node upstream
and the ref silently switches to the library with nothing to explain it.

The card now says "also in dusk-marten — other nodes don't share pools" (or
"N other nodes" past one), in info blue rather than the drift amber, because
nothing is wrong — the ref is behaving correctly and the user simply cannot
see why from anywhere else.

`findWildcardHomesElsewhere` walks the whole graph, subgraphs included: a node
holding the uuid off to one side is exactly as irrelevant to the ref, and just
as confusing to look at. It is injected as a FUNCTION and called once per card
open rather than exposed as a reactive map — the walk is graph-wide and only an
open card ever reads the answer, so polling it on the 400ms cadence would burn
work nobody reads.

Wired through the same reader-prop path the other graph walkers use
(`wildcardHomesReader` in the mount glue), so the component stays mountable
headless with no graph.

The exclusion test — the asking node must not report itself, since its own
modules ARE the ref's pool — was checked to FAIL when the guard is removed.
2026-08-02 00:36:09 +03:00
DumiFlex 7e26211fa5 fix(chips): stop the container tooltip overlaying the ref hover card
Hovering a chip raised two popups at once: the chip's own hover card, and the
native tooltip belonging to whichever container it sat in — the derivation
rule/branch summaries put the full row text on a `title` so a clamped value
stays readable. They landed on top of each other.

The chip only rendered a `title` when it was filtered, so an unfiltered chip
had none and the browser walked up to the container's. An EMPTY title is not
the same as no title: per HTML it means "this element has no advisory
information" and stops that walk. So the chip now always carries `title=""`.

Nothing is lost — the filter's "reads as" and the excluded-null note already
render inside the hover card, so the native tooltip was duplicating them. The
seven tests that asserted the old title now assert the card instead, which is
where a user actually reads it.
2026-08-01 21:08:16 +03:00
DumiFlex 61288c5c2e feat(refs): the nested-ref hover names the pool it counted, and flags drift
The card read the live library unconditionally, but the engine resolves a
nested ref against the Context node's own frozen snapshot whenever the uuid is
also picked there, and only falls back to the library otherwise. So a node
holding a drifted snapshot reported the library's numbers while the graph ran
the snapshot's — "the SPA says 1 of 11 options match, the graph says 0 of 10".
Both counts were right about a different pool, and nothing said which, so
neither could be checked against what actually happened.

`extension/context-pools.ts` mirrors the engine's precedence: the node's pool
wins when present, the library answers otherwise. The card now counts against
whichever won, and names it — "pool: this node · Pose pool" vs "pool: library".
When the loser disagrees it adds "library has N — refresh to use it", so a
differing number is explained rather than hidden; silently showing only the
effective pool would have left the original confusion intact, just quieter.

Disabled modules are deliberately KEPT in the map. Disabling a module stops it
binding its own `$var`; it does not remove the pool the engine still resolves
nested refs against, and dropping it here would make the card disagree with the
engine in precisely the case where a user is already unsure where a value came
from.

Wired by provide/inject rather than a seventh threaded prop: RefChip sits six
components below the widget (row → edit modal → section → value chips →
preview → chip) and not one layer between has any business knowing about
option pools. Absent injection — the SPA library views — every ref reads the
library, which is correct there since there is no node.

The three tests that pin the precedence were checked to FAIL when the context
branch is disabled.
2026-08-01 20:34:24 +03:00
DumiFlex 22f7dedcf2 fix(autocomplete): don't reopen the picker when the caret sits behind a chip
Type a space after a `$style_fx` chip, delete it, and the caret settles flush
against the chip's trailing edge — which reopened the suggestion popover even
though nothing was being filtered and the chip already existed. Both surfaces.

A committed chip serialises back into raw text as exactly what the user would
have typed to create it, so `probeAutocomplete` — which works on raw text —
scans back from the caret, finds the chip's own `$`, and reports a live query.
From the string alone the two cases are identical; only the DOM distinguishes
them, because a chip is an atom element and in-progress text is not.

`chipRawSpans` carries that distinction into the raw coordinate space by
measuring each rendered chip with the SAME `childRawLen` the caret mapping
uses (hoisted rather than copied — two measurements of the same thing drift).
A trigger landing inside one of those spans suppresses the popover.

Surfaced by the earlier deletion re-probe fix: before it, deletion never
re-probed at all, so this path could not fire. The regression test asserts
zero suggestion rows after the exact reported sequence, and was checked to
FAIL (2 rows) with the guard disabled — otherwise it proves nothing.
2026-08-01 20:17:32 +03:00
DumiFlex 96c65a7d1b fix(derivation): cap the preview VALUE, restore the wrapping layout
Two previous attempts solved the wrong problem. The rule/branch summaries were
forced onto a single line — first with a gradient mask, then with
`text-overflow: ellipsis` — and both destroyed the thing that made the row
useful. The mask faded every pixel including the ref chips; the ellipsis
version was worse still, because the children were `inline-flex` and an
inline-flex box is ATOMIC: the browser cannot clip through it, so a value that
did not fit whole was dropped entirely and replaced by a bare `…`.

The layout was never the problem. Wrapping is what lets a four-chip branch
show all four chips, and that is the readable, wanted behaviour. The only
actual complaint was that one paragraph-length override ran to dozens of lines
and pushed the rest of the modal off screen.

So the layout goes back to exactly what it was (`display: flex; flex-wrap:
wrap`), byte for byte, and the cap moves to the VALUE: `clampPreviewValue`
cuts at 120 characters. Normal rows are untouched — every chip value in a real
derivation is well under the limit — and only the outlier gets cut. `title`
still carries the full text on hover.

The clamp never splits a `@{...}` token: half a token renders as literal text
rather than a chip, which reads as corruption rather than truncation, so a
token that would straddle the limit is dropped whole.
2026-08-01 20:00:35 +03:00
DumiFlex 1224d35010 fix(refs): discover @{uuid} refs written outside a wildcard payload
A nested ref in a derivation resolved to empty whenever the wildcard it points
at was not ALSO picked into the same Context node — no matter what the live
library held, and no matter how the ref was filtered.

Root cause is discovery, not resolution, which is why it looked like a
resolution bug. The catalog is synthesised from wildcard modules only, and
`walk_transitive_refs` was seeded from those uuids and followed refs found in
wildcard payloads only. A `@{uuid}` is legal on other surfaces too — most
importantly a derivation rule's action value, and the per-instance
`action_value_overrides` the edit modal writes when a user retargets a branch
on the canvas. Neither was ever scanned.

So such a ref's target reached the catalog only by coincidence: when the user
happened to also have that wildcard picked in the node. Removing it — a
reasonable thing to do once a derivation references it by uuid — dropped it
with nothing to put it back, and the ref became `unknown_ref` → empty string.

`ref_seed_uuids` (engine/modules/snapshot.py) now collects refs from every
module's `payload` AND `instance`, and both catalog builders seed the walk
with them. The instance half matters on its own: a canvas-side retarget lives
there and is not part of the library payload the walker would otherwise see.

Both builders are fixed, not just the graph one. `wp_api/preview.py` had the
same seeding, so the SPA preview resolved against a pool the engine would not
use — that is precisely how "the SPA says 1 of 11 options match, the graph
says 0 of 10" arose. Fixing one and not the other would have left the two
disagreeing in the opposite direction.

Embedded-first precedence is unchanged: a picked module still shadows the
library, so drift detection and workflow reproducibility hold. This only adds
targets that were previously absent entirely. An unresolvable uuid still warns
and resolves to empty rather than raising.
2026-08-01 19:42:27 +03:00
DumiFlex 78f831ad69 fix(derivation): truncate rule previews with an ellipsis, not a fade
The gradient mask was the wrong tool. `text-overflow: ellipsis` is ignored on
a flex container, so the previous fix reached for `mask-image` to signal "cut
off" — but a mask fades every pixel under it, chips included, so the tail of
each summary went unreadable rather than merely clipped. The user could no
longer see the ref chips that are the most informative part of the row.

Switching the summary to `display: block` puts it in an inline formatting
context, where the browser draws a real `…` at the clip point and leaves
everything before it at full opacity. Children become `inline-flex` so they
still sit on one line, with a margin standing in for the flex `gap`.
2026-08-01 19:37:36 +03:00
DumiFlex acb5848da3 fix(icons): a glyph that does not exist, and three that sat off-centre
Four things the user caught in one screenshot pair.

**`brush` is not a PrimeIcons glyph.** It rendered as an empty grid cell, which
reads as a layout bug rather than a typo — an unknown `pi-` class produces no
warning and no error, just nothing. Replaced with `sliders-h` ("tune the look";
the set has no brush/paint glyph at all, and `palette` already carries "art
style").

The durable part is `icon-catalog.ts` + its test: the curated list moved out of
the SFC so a spec can check every name against the installed `primeicons.css`,
which is the authority. Parsing the stylesheet beats hard-coding a name list
that would go stale on the next PrimeIcons bump. Verified live afterwards —
all 40 cells resolve a glyph.

**Category icons sat above their label in Select options.** The option row is
`align-items: flex-start` (deliberate — a 2-line label must keep the tick at
the top), so a 14px icon aligned to the TOP of an 18.75px label box rather than
to its first line. Measured 2.38px high before, 0 after. Fixed by centring the
icon and the colour dot against one line box, published as
`--wp-select-line-h` and used by both the label's `line-height` and the icon's
`height` — a `1.5em` on each would have meant two different heights, since the
icon runs at 12px and the label at 12.5px. Scoped to the option: the trigger
row is `align-items: center` and would be thrown off by the same rule.

**The icon-picker trigger jumped when a glyph was picked.** An em-dash and an
icon have different advance widths and baselines; both now share one fixed
16px slot. Grid cells got `line-height: 1` for the same reason — the icon
font's line box is taller than the mark and pushes it off centre inside an
otherwise centred flex cell.

**Import quick-select presets had no icons** where Export's do. Wiring them up
exposed that `category` had no row in `KIND_ICON_MAP` and was falling through
to the generic `pi-circle` on BOTH tabs; it now maps to `pi-bookmark`, matching
the Categories page's own empty state.
2026-08-01 19:03:02 +03:00
DumiFlex 2c6de11c8c feat(categories): icons everywhere a category appears, and one chip to render them
Categories have carried an `icon` column since the picker landed, but only the
Categories page ever showed it. Everywhere else a category still reduced to an
8px colour dot — which stops disambiguating anything once a library holds more
categories than there are hues a user can tell apart at that size.

Icons now render in the category dropdowns (filter + bulk-assign), in the row
chips on every list view, on the dashboard, and in both import/export pickers.
The icon replaces the dot rather than sitting beside it, tinted with the
category's colour so nothing is lost; a category with no icon still shows the
dot exactly as before.

Getting there meant collapsing three sets of copy-paste, because each would
otherwise have needed the same edit applied by hand N times — the failure mode
being that one gets missed and quietly renders the old way:

- `utils/category-options.ts` replaces thirteen identical
  `categoryStore.items.map(...)` expressions across nine files. The two
  variants differ only in the null choice ("All categories" vs "(none)"), so
  that is the only parameter.
- `components/CategoryChip.vue` replaces ten hand-written
  `<span class="wp-cat-chip" :style="catChipStyle(...)">` blocks.
- `ui/select-types.ts` holds `SelectOption`, which lived in `<script setup>`
  and so was invisible to plain `.ts` importers (they see the `*.vue` ambient
  shim, whose only export is the component). Select.vue re-exports it, so every
  existing `from "./Select.vue"` import keeps resolving. ModuleListView's
  hand-mirrored copy of that shape is gone with it — the copy had already gone
  stale against `icon`.

Also reorders the Categories page: icon and colour now come before the name, in
the form and in the table. They compose the chip the user is building, and
left-to-right now matches how the chip actually renders.
2026-08-01 09:53:47 +03:00
DumiFlex fe6f6ec451 feat(import): search + per-row detail in the import picker, mirroring export
The export picker grew a search box and subtitle lines when a 261-item
library made it a scroll-hunt. The import picker had the same problem the
moment anyone re-imported a whole-library export: duplicate display names,
and no way to jump to a known id.

Both sides now share the machinery instead of carrying two copies:

- `picker-subtitle.ts` — the per-kind detail line (`$outfit · 12 options`,
  `3x4 matrix`, `2 modules · 1 nested`). Extracted from ExportTab verbatim.
  Typed structurally rather than against `api/types`, because Export feeds
  it live library rows while Import feeds it migrated payload rows that are
  shape-validated but never narrowed to those interfaces; both satisfy the
  loose shape, so neither side needs a cast. Adds `entitySubtitle(kind, row)`
  for Import, which walks all 8 buckets in one v-for and so cannot pick the
  right function at the call site the way Export's per-bucket builders can.
- `PickerSearch.vue` — the box itself. Presentation only; each host owns its
  query and decides what "matches" means for its own rows.

Filter scope follows the same split Export settled on, because it is a
correctness question rather than a cosmetic one:
  - section select-all acts on VISIBLE rows, so deselecting a filtered
    section cannot silently drop picks the user can no longer see;
  - quick-select presets stay unfiltered — "select all wildcards" means the
    kind, not the current search result;
  - a section with matches force-opens, since a hit hidden behind a
    collapsed header reads as no hit at all.

Test fixtures for the new specs nest `variable_name` / `options` under
`payload`, matching what the server actually exports — `_row_to_module`
nests them and every migrator preserves that nesting. A first draft
invented a flat shape and produced empty subtitles.
2026-08-01 09:37:37 +03:00
DumiFlex 0f4ba28105 fix(derivation): actually truncate rule previews, and accent state recoveries
The truncation fix did not work. Setting `white-space: nowrap` on
`.rule-head__summary` was necessary but not sufficient: the value chips render
through RichTextPreview, which declares `white-space: pre-wrap` on `.wp-rtp`
and `.wp-rtp__text` THEMSELVES. A direct declaration beats what those elements
would inherit from an ancestor, so the paragraph kept wrapping to hundreds of
lines. `ui/Select.vue` already carried this exact workaround for its trigger
label; the derivation summaries needed the same `:deep(...) !important` push
down the tree.

Diagnosed from a live DOM dump rather than assumed: the `title` attribute
added by the previous commit was present in it, which ruled out the stale-
bundle theory I had reached for and pointed at cascade instead.

Also accents the context-menu action that matches the row's current state, so
the menu points at the fix for the badge the user just saw:
  missing  -> Push to library (re-link or add)
  drifted  -> Reset / Refresh from library (pull the newer version)
  modified -> Push to library (save local edits up)
States are ranked so exactly one library action accents — a row can be several
at once, and two competing accents say nothing. Each accented item also gains
a subtitle naming the reason.
2026-08-01 08:39:55 +03:00
DumiFlex 727bd089a4 fix(bundles): an unlinked bundle reads as missing, not modified
Reported: a bundle showed MODIFIED while the push modal said it wasn`t linked
to any library entry. Modified against what?

`isBundleMissingFromLibrary` early-returned false when the bundle had no
`library_id`, so MISSING only ever meant "had a link and it is gone" — an
unlinked bundle could not raise it. That left MODIFIED as the only badge, and
MODIFIED compares the children against `snapshot_fingerprint`, a baseline
captured from a library entry that is not there. It was diffing against a
ghost. Same class of gate as the re-link bug: a check that assumed the
library_id case was the only way to lose a link.

MISSING now covers both shapes — a `library_id` the poll no longer knows, and
no `library_id` at all. MODIFIED is suppressed for the second: with no insert,
there is nothing for the children to have diverged from. The deleted-entry case
keeps MOD, where the local snapshot IS a real baseline.

Badge tooltip and the context-menu subtitle stop asserting the entry was
DELETED — an absent id reads identically whether it was deleted, re-imported
under a new id, or arrived in a shared workflow. They now state the observable
fact and name Re-link before save-as-new, which is the recovery the old copy
steered users away from.
2026-08-01 02:46:04 +03:00
DumiFlex 470133f953 feat(relink): match bundles on child content, not just the stored hash
Bundles already had a re-link path — my earlier claim that they were scoped
out was wrong, twice: I read a comment about bundle FINGERPRINT VERIFICATION
during import as if it covered re-link. What they actually had was the same
gate modules had, requiring a stored `inserted_at_hash`, so a bundle that lost
its link had nothing to match on.

`computeBundleFingerprint` cannot serve here: it folds in per-instance `_uid`,
`meta` and `instance` to answer "have MY children been edited since insert", so
a fresh insert never matches the library row it came from. Adds
`bundleContentKey`, the ordered list of children`s `(type, payload_hash)` —
only what survives an insert, so a workflow bundle and its library row agree.
Child order is significant (a bundle IS an ordered range) and a hash-less child
still occupies a slot rather than vanishing from the shape.

Gate widened to `needsRelink` to mirror the module modal, and the "deleted
upstream" note replaced with the observable fact plus a pointer at Re-link,
in an informational rather than danger tone.
2026-08-01 02:03:33 +03:00
DumiFlex 388af28a2b feat(relink): offer re-link to hash-less modules, and stop guessing why
Re-link was gated on the row carrying a `payload_hash`. A row without one was
read as "never library-tracked, nothing to re-link to" — true for a module
authored in the widget, wrong for the cases that actually bite: a workflow
shared by someone else, or a row whose stored hash was lost. Those still
correspond to a library entry, and the user was pushed to "Save as new entry",
which is how duplicates got made.

`moduleFingerprint` cannot help here — it takes `payload_hash` as an INPUT, so
it says nothing about a row missing one. Adds `localPayloadFingerprint`, which
hashes the payload CONTENT instead. It deliberately does not mirror the
engine`s hashing: both sides are computed locally from live payloads, so
self-consistency is the only requirement and cross-language canonicalisation
never enters the picture. Object keys are sorted recursively (JSON key order
is not meaningful and payloads round-trip through APIs that reorder them);
array order is preserved, since option and rule order IS meaningful.

Matching now runs strongest-signal-first: server `payload_hash`, then the
local content key (hash-less drafts only), then name. A present-but-different
hash still reads as "content differs" — a stale content key must never upgrade
a real difference to identical. The cross-kind gate is unchanged. Matches made
without a server hash are tagged `matchedByContent` and labelled
"identical (content)" so the UI never implies the authoritative hash agreed.

The note also stops asserting a cause it cannot know. "has been deleted
upstream" was one explanation among several that look identical from here; it
now states the observable fact — the id isn`t in the library — and points at
Re-link when a match exists, in an informational rather than danger tone. Red
plus a "Save as new entry" recommendation was actively steering users into the
duplicate.

Bundles still have no re-link path; that is the remaining half of this issue.
2026-08-01 01:51:35 +03:00
DumiFlex 7c18fcc208 feat(chips): name the library module that binds a $var in the SPA
Canvas var chips gained producer attribution; the SPA still said only
"binds at runtime". It has no chain to walk, but it does have the catalog —
and with several near-identical modules binding the same name, "which module
is this?" is exactly the question the library editors raise.

Adds `buildLibraryVarProducers`: the same per-kind extraction
`collectLibraryVarHints` already does, but keeping module identity instead of
deduping it away. A module that binds one var from several places — a
derivation with three branches writing `$pose`, a fixed_values sheet with a
repeated row name — counts ONCE, otherwise the card claims three modules bind
a name only one does.

Deliberately no ranking. The library has no execution order, so which producer
wins depends entirely on how they are later arranged in a Context node. The
card names the first and reports how many others share the name, via a
`siblingLabel` that swaps the canvas`s "overrides N earlier writes" for
"N other library modules bind this name". Same reason `nodeLabel` is now
optional: with no graph there is no node to name, so the clause is omitted
rather than filled with something invented.

Wired into the Derivation and Combine editors, the two SPA surfaces with
`$var` chips.
2026-07-30 20:15:15 +03:00
DumiFlex 4cf497b507 feat(chips): name the module and node that produce a $var
The var-chip hover could only confirm a name existed. With near-identical
modules bound to the same var across several nodes, that is not actionable —
the question is always WHICH one wins.

Adds `collectUpstreamProducers`: the same chain walk and last-write-wins
ordering as `collectUpstreamKinds`, widened to keep identity instead of
discarding it. Covers all three producer sources — WP_Context modules,
WP_ContextInjector rows, and the WP_ContextLoop iteration vars (`$name` +
`$name_total`). Muted / bypassed nodes are skipped, matching runtime.

Node identity follows what each node actually has: WP_Context nodes report
their stable `adjective-noun` codename, the same one their header shows;
the Injector and Loop have no codename scheme, so they use a user-set title
and fall back to a plain kind label.

The card now reads "wildcard · Outfit · in dusk-marten" with the module id,
and adds two states worth surfacing: "overrides N earlier writes" (runtime is
last-write-wins, so it names the winner and says others exist) and "internal —
stripped from the final prompt", which is the most confusing state a var can
be in.

`graphAware` is an explicit prop rather than inferred: a graph-aware host that
finds no writer must say "no upstream producer", while the SPA — which has no
chain to walk — must not make that claim at all.

Raises the bundle budget 461 -> 468 KB. Approved standing: the maintainer
asked for the budget to move rather than features to be trimmed to fit.
2026-07-30 16:40:25 +03:00
DumiFlex 51eabadc36 fix(ui): truncate rule previews instead of clamping, unbury hover cards
Rule head + branch summary: the two-line max-height clamp stopped the modal
blowing out but sliced the last line through the middle, which read as broken,
and there was no way to see the rest. Now a clean single line with a fading
trailing edge (flex containers ignore `text-overflow`) plus a `title` carrying
the full plain-text rendering — `branchSummaryText` / `ruleSummaryText`.

Hover cards moved to their own tier (10030) above the popover tier (10020).
Raising popovers earlier put dropdown menus ABOVE these cards, so an `@ref`
chip inside a constraint-exception Select option had its hover card drawn
underneath the very menu it belongs to. A chip can live inside a popover, so
its card has to clear it. Applies to RefChip and PairBadge.

Select option rows now wrap to two lines instead of clipping at one. An
unscoped `white-space: nowrap !important` was forcing single-line rich content
in the menu, which cut exception values — a phrase plus a nested `@ref` —
exactly where they stop being distinguishable. The trigger keeps its
single-line clip: it has a fixed height and wrapping there blows up the row.

Fixed values: Enter inserts a newline again. Swallowing it was parity with the
single-line `<input>` this replaced, for its own sake — the engine stores the
value verbatim, multi-line round-trips, and the modal saves on Cmd/Ctrl+Enter,
so plain Enter was free all along.
2026-07-30 16:06:21 +03:00
DumiFlex 96b4141aed fix(ui): cut the changelog dump, clamp rule previews, fix the overflow hint
Update dialog: release bodies end with a `<details>` block holding the raw
per-commit list. GitHub collapses it behind a click; `normalizeReleaseHtml`
unwrapped it inline and dumped every commit message into the preview, burying
the human-written "What`s new" the dialog exists to show. It is now stripped
before render, along with the `---` that introduced it. Matched on the summary
text rather than stripping every `<details>`, so a future collapsible still
unwraps as before. The dialog already links out to the full changelog.

Derivation rule previews: a rule head and a branch summary are PREVIEWS, but
neither was clamped, so one paragraph-length action value wrapped to hundreds
of lines and pushed every other rule, the runtime row and the footer buttons
off screen. Capped at two / three lines; the expanded body and the override
inputs still hold the full value.

Overflow fade: it only appeared once a field was typed into — never on a box
the user can scroll but not edit. A mount-time measure runs before the
max-height cap applies, reads `scrollHeight === clientHeight`, and concludes
there is nothing below. Measurement now happens after layout via rAF, and a
ResizeObserver keeps it honest across manual resizes and reflows. Applied to
both RichTextInput and the ComfyUI fixed-values row, and the fixed-values row
gained the fade it was missing entirely.

Dead CSS: drops the `.wp-tpl__menu*` selectors — Combine`s TemplateSection has
no insert menu, so they matched nothing (`.wp-tpl__reset` IS still live and
stays). Removing them exposed that their `.ibm__menu*` partners were only
passing `check:css-isolation` because a wp-prefixed selector shared the rule,
so those are now `.wp-ibm__menu*` and the check passes on its own merits.
2026-07-30 15:16:06 +03:00
DumiFlex 963c36fc18 fix(ui): rework the overflow fade, and make branch headers fully clickable
The "more below" fade read wrong on both counts it was meant to get right.
Its 1px side insets left a visible seam down the left edge where the band
stopped short of the border, and blending toward the field background painted
an opaque strip that washed out the line underneath instead of hinting at it.

Now full-bleed (`inset: auto 0 0 0`), clipped by the wrapper`s own overflow +
radius so there is no seam, and the gradient runs to a TRANSPARENT accent so
text keeps showing through. A 1px inset accent line along the bottom does the
actual attention-getting, which lets the wash stay light.

Derivation branch headers (SPA) now toggle from anywhere on the bar. The
chevron was a ~14px target on a full-width header that already looked
pressable. Clicks originating inside a control are ignored, so the chevron
does not double-toggle and the enable checkbox / delete action keep their own
behaviour.
2026-07-30 03:41:45 +03:00
DumiFlex 59e51d34c0 feat(spa): quick-fix pass — nav, export detail, input scaling, tag collapse
Templates: drops the Template column. A template string is a whole prompt
skeleton, so a table cell only ever showed a truncated fragment while stealing
the width the name needed; the row expansion already shows it in full. The
column now carries length + distinct `$var` count.

Nav: Wiki loses its sidebar entry — it duplicates Documentation`s content on
GitHub and sat directly above View Source. It now lives at the foot of the
Documentation view`s own nav. "Report an issue" becomes "Feedback & support",
opening a modal that offers Discord (noted as faster) and GitHub, since most
feedback is a feature request rather than a bug. Discord also gets a permanent
topbar icon.

Export: sections with search matches auto-expand — a filtered but collapsed
section gave no clue whether the query had found anything. Every entity kind
now carries row detail, not just wildcards: values / rules / matrix size +
exceptions / children + nested / template length. Combine reads `output_var`
and `input_vars` (an earlier guess of `output_variable` matched nothing, so
combine rows showed nothing at all). Detail is right-aligned in the name
column so it forms one scannable run. Kind presets carry their kind glyph.

Constraints: source/target dropdown meta gains tag + axis counts — that IS the
matrix about to be built, so the grid size is worth knowing before picking.

Inputs: max auto-grow drops from 40vh to 12rem (wrap) / 14rem (multiline) —
40vh let one field eat half the viewport before it scrolled. ComfyUI
fixed-values gains the manual resize handle it was missing, and the SPA
derivation value fields gain `wrap`, so both match combine/derivation
behaviour. Adds a restrained accent-tinted bottom fade when a capped box is
hiding content below the fold.

Wildcards (SPA): per-option tag chips cap at 4 behind a `+N`, mirroring the
canvas OptionRow, and the Sub-Categories section collapses via a `+`/`x` in
its header with a tag/axis count while shut. A fully tagged wildcard pushed
the options table off screen.

Select: hover titles on the trigger and option rows, so a value clipped by a
fixed-width trigger is still readable — constraint exception sources run long.
2026-07-30 03:32:30 +03:00
DumiFlex 4e746ce2aa fix(ui): let dropdown scrollbars work, and put popovers above the modal tier
Two separate defects, both making a visible dropdown unusable.

Scrolling closed the list. `onWindowScroll` was a capture-phase listener on
window that closed the popover on ANY scroll — including scrolls originating
INSIDE the popover. So wheeling over the list closed it, and dragging its
scrollbar closed it mid-drag, leaving a half-typed reference behind. It now
ignores scrolls inside the popover, and a genuine outside scroll re-anchors
via `positionPopup` instead of closing: losing an in-progress token because
the page moved is a worse outcome than following the input. Matches what the
injector menu and VarAutocompleteInput already do.

Popovers rendered behind modals. Every teleported popup sat at z-index 9999
while modal overlays run from 9999 (ModalShell) to 10010
(InjectorBindingModal). At equal z-index the popover won only on DOM order,
so inside any 10000+ modal it was buried — the reported "injector ref
dropdown appears behind the modal". Moves the whole popover tier to 10020:
the autocomplete popover, the shared `.ibm__menu` / `.wp-tpl__menu` insert
menu, the subcategory filter picker, `ui/Select`, and IconPicker.

Also: the Pool collapse chevron was a 9px glyph with no hit area, so nobody
could tell it was pressable — now an 18px target with a hover box. And the
seed-list modal goes 486px to 560px, since its toolbar and per-row toggles
were being squeezed into near-square boxes to fit.
2026-07-30 02:45:48 +03:00
DumiFlex 8d6d4e4b10 fix(autocomplete): re-filter the suggestion list when characters are deleted
Reported repro: type `@act` (4 matches), narrow to `@act_` (2), delete the
`_`, and `@act` still shows 2. The list never followed a deletion, on either
surface.

Backspace and Delete both `preventDefault()` and perform the deletion
themselves via `deleteRawRange`. Suppressing the native delete means no
`input` event fires — and `onHostInput` is the only other caller of
`refreshAutocompleteFromHost`. So `acQuery` kept its pre-deletion value and
`acItems`, computed off it, stayed frozen at whatever it had narrowed to.
That is also the real cause of the earlier "type something unmatched and you
must clear the whole field" report: the field was fine, the list was stale.

Both delete branches now re-probe. The call sits in a SECOND `nextTick`
because `restoreCursorAtChar` ends with `host.focus()` and `onHostFocus`
schedules its own tick that can reposition the caret — probing in the first
tick reads the pre-settled caret at offset 0, the probe returns null, and the
popover closes instead of re-filtering. Verified: probing one tick earlier
left the list empty where an equivalent input event produced 3 rows.

Also reverts the earlier partial-brace / space handling in
`probeAutocomplete`. That was built on a misreading: `@{uuid#name}` is an
internal serialisation the user never types, wildcards are addressed by
variable binding rather than display name, and a space is the settle
delimiter that commits a chip — so neither is a valid query shape and the
bare-identifier scan was already correct.
2026-07-30 02:41:09 +03:00
DumiFlex 7f07f99951 perf(resolver): refresh wildcard snapshots on focus instead of waiting out a timer
The 60s freshness window was the only thing invalidating cached snapshots, so
after editing a wildcard in the SPA you sat and waited for the canvas to
agree. The window was arbitrary — it existed because the canvas and the SPA
are separate pages with no shared invalidation event to subscribe to.

There IS an observable signal: the user coming back. They edit in the SPA
tab, switch to the ComfyUI tab, and expect the canvas to be current.
`markAllStale` now runs on `focus` (clicking between windows) and on
`visibilitychange` (tab switch), so the refresh happens exactly when they
look at it.

The mark is cheap and the refetch stays lazy — driven by whatever `ensure()`
the next reactive tick makes — so a focus event on a graph with no `@{}` refs
costs nothing. Only the freshness stamps are dropped, never the cached
values, so nothing flickers back to a raw uuid mid-refresh. Transient failure
tombstones clear too, since an explicit "check again" should retry rather
than wait out RETRY_TTL_MS; permanent 404s stay.

The 60s timer stays as a backstop for the case where focus never changes —
two visible windows on one desktop.
2026-07-28 17:27:04 +03:00
DumiFlex e35ebb25a7 feat(export): make Copy payload emit an importable envelope
Copy exists so a module can be shared WITHOUT walking the Export tab, which
means what lands on the clipboard has to be paste-able into Import. It was a
bare engine row, and `parsePayload` rejects that outright — it requires
`{schema_version, bundles, wildcards, fixed_values, combines, derivations,
constraints, categories}` and never gets as far as looking at the entity.

`buildCopyableRow` now emits a complete export envelope carrying the one
entity in its own bucket, with the siblings empty. Bundles (which have
`children` and no `type`) go to `bundles`. `schema_version` still comes from
the same `schemaVersionForPayload` the publish paths use, so a copied entity
and a published one can never disagree about their shape.

An unrecognised entity kind is now refused rather than copied: an envelope
with every bucket empty reads as "copy worked" and then imports nothing,
which is worse than a visible failure.

Scope: this carries the one entity, not its dependency closure. A wildcard
nesting `@{uuid}` refs still imports with those dangling, and the Import
tab`s missing-dependency UI reports them; shipping the closure stays the
Export tab`s job since it has the UI to show what is being dragged along.

Tests round-trip the copied JSON through the REAL `parsePayload` rather than
asserting a hand-written shape.
2026-07-28 17:22:46 +03:00
DumiFlex 693f7191d0 fix(theme): read ComfyUI dark marker from <html> as well as <body>
The extension rendered in light mode on top of a dark ComfyUI. Not a broken
light theme — it was STUCK in light.

ComfyUI moved the `dark-theme` class between frontend releases. Verified
against two live builds on this machine:

  1.45.21 -> <body class="litegraph grid dark-theme ...">, <html> bare
  1.47.10 -> <html class="dark-theme">, body no longer carries it

`hasDarkClass` only read `document.body`, so on any current build the check
returned false and `detectInitialTheme` reported "light" — the extension then
painted its light tokens onto ComfyUI`s dark chrome. Evaluated against the
live 1.47.10 DOM, the old predicate returns false where the new one returns
true.

Now checks EITHER element and observes both, so a theme switch is caught
whichever element the running frontend mutates and older builds keep working
unchanged. Light is still the genuine signal when neither carries the class.

Tests pin both eras plus the html-side switch.
2026-07-28 17:00:18 +03:00
DumiFlex 2896e11d26 fix(resolver): expire cached wildcard snapshots so the canvas stops drifting
The canvas and the SPA disagreed about the same derivation: the SPA reported
"1 of 11 options match" while the ComfyUI instance reported "0 of 10".

`ensure()` short-circuited on `cache.has(u)` and nothing but the test seam
ever cleared the map, so a SUCCESSFUL snapshot lived for the lifetime of the
page. Failures already had a 30s retry TTL; successes had none. The canvas
therefore kept whatever a wildcard looked like the first time it was
referenced — add an option in the SPA and the canvas reported the old count
until a full reload. The two surfaces are separate pages with no shared
invalidation event, so freshness has to be time-based.

Adds a 60s freshness window, tracked in a sibling `cachedAt` map so the
public `PreviewLookup` shape stays free of bookkeeping. The stale entry is
deliberately KEPT while the refresh is in flight, and kept even if the
refresh fails, so a resolved label never flickers back to a raw uuid. The
permanent-failure check moved ahead of the staleness check so a stale entry
whose refresh 404s cannot re-enter the queue on every 400ms reactive tick.

Also switches the SPA wildcard option value to `wrap`. It was a fixed 34px
single-line field with a horizontal scroll, so a long option value — the
paragraph-length ones users actually write — was effectively invisible.
2026-07-28 04:31:38 +03:00
DumiFlex dd793c536e fix(ui): three layout defects — seed rows, picker chips, playground banner
Seed-list modal: a locked row carries three fixed-width toggles plus a hard
178px seed input and a roll button. On the 486px modal that overran the panel
— the input clipped at the right edge and the roll button was pushed out of
view entirely. The value column now shrinks (`min-width: 0`, 178px as a max
rather than a fixed width), and a long derived seed ellipsis-clips instead of
shoving the DERIVED tag off the edge.

Module picker: the selected-module chip rendered its name as a bare text
node, so a long name like "Accent piece (color-neutral)" wrapped onto a
second line inside a chip fixed at 22px tall and spilled out of its own pill.
Wrapped in a span that ellipsis-clips, with the full name on hover.

Display playground banner: the header scrim was hardcoded to a near-black
rgba over the brand gradient. Under the light theme the band stayed dark
while `--wp-text` flipped to a dark ink — dark text on a dark banner. The
scrim now follows the theme, keeping the same 0.85 knock-back either way.
2026-07-28 04:20:00 +03:00
DumiFlex 6c4589315a fix(wildcard): tame oversized tag chips + match SPA probability precision
Two problems in the canvas wildcard modal, both worst on exactly the
well-authored wildcards the extension is for.

Option rows: a fully tagged option carries 8+ sub-categories (hue,
temperature, tone, saturation, several suitability flags). Wrapped inside the
96px CATEGORY column they stacked into eight lines, making ONE row taller
than the option list was worth. Shows the first three behind a `+N` pill that
expands per row — the tags stay reachable, they just stop dominating.

Tag pool: 40-odd pills pushed the option list, the thing the user came to
edit, most of a screen down. The section now collapses from the header, and
reports how many tags are ticked while collapsed. Starts open so the pool
stays discoverable.

Probability: the modal rendered `Math.round(p * 100)`, so with 30+ options
every entry below 0.5% read as a flat "0%" — a legitimately weighted option
looked impossible. Now delegates to the SPA`s `formatProbability`, which
already existed for exactly this reason, so both editors agree digit for
digit.
2026-07-28 04:09:58 +03:00
DumiFlex 3f6b856eec feat(categories): add an icon picker
The `icon` column has existed end-to-end — engine column, `_UPDATABLE_FIELDS`
in wp_api/categories.py, `CategoryRow.icon` in the TS types — with no UI to
set or display it, so every category rendered under the same generic bookmark.

Adds `IconPicker`, a teleported grid of curated PrimeIcons grouped by the
kinds of things people actually categorise prompt modules by (subject, style,
scene, structure, signals), plus a "No icon" reset. PrimeIcons ships ~300
glyphs; offering all of them would need its own search UI and most of the set
is chrome (chevrons, spinners, media transport) that means nothing as a
library label.

Glyph names are stored unprefixed ("palette"), matching what the column
already held. Wired into both the new-category form and the row editor, with
an Icon column in the table.
2026-07-28 03:58:52 +03:00
DumiFlex bbc7556af2 feat(inputs): let fixed-value and combine fields grow with their content
Users routinely paste an entire sentence into a value, and the fields showed
a sliver of it with no way to see the rest.

- Fixed values, ComfyUI modal: the value was a single-line `<input>`. Now an
  auto-growing `<textarea>` that wraps, sized to content and capped at 30vh
  before it scrolls. Enter is swallowed — a fixed value is one string and the
  engine has no meaning for an embedded newline — so it still behaves like the
  input it replaced.
- Fixed values, SPA: swaps the fixed 2-row `multiline` box for `wrap`, the
  existing grow-to-fit mode (one row minimum, 40vh cap, manual resize handle).
  A fixed height either wasted space or hid most of a long value.
- RichTextInput `--multi`: adds the 40vh cap + scroll that `--wrap` already
  had. Uncapped, pasting a paragraph grew the box to thousands of pixels and
  pushed every control below it off-screen.
- Combine editor: the output variable moved out of its 220px column beside the
  template to its own full-width row ABOVE it. That column squeezed the field
  that actually needs room to grow, and read as a footnote to the template
  rather than as the thing the combine produces.
2026-07-28 03:49:02 +03:00
DumiFlex 5064f084a3 feat(export): add cross-bucket search + $variable/option-count row detail
A 261-item library made the export picker a scroll-hunt, and because many
entities share a display name (five wildcards all called "Outfit"), scrolling
was the only way to find a specific one — and it still could not tell you
which was which.

Adds a search box matching name, short id, and the new row subtitle, plus a
`subtitle` on PickerRow rendering "$outfit · 31 options" — the two facts that
actually distinguish same-named modules. Mirrors what the canvas
ModulePickerModal rows already show.

Section counts and select-all read the VISIBLE rows so the header checkbox
describes what the user is looking at, but select-all leaves rows hidden by
the search untouched: silently dropping a filtered-out selection on a
"deselect all" click would lose work the user cannot see. The footer counter
still reports the true export size.
2026-07-28 03:37:51 +03:00
DumiFlex 67761fbba8 feat(var-picker): add type-to-filter to the Var-to-Int/Bool/Float dropdown
A long Context chain produces dozens of upstream vars and the picker menu was
an unfiltered scroll — the only dropdown in the extension without search.

Adds a sticky filter field that focuses on open, matching with or without the
leading `$` so typing `see` finds `$seed`. Enter picks the sole remaining
match (the common case after a few characters) and Escape closes without
changing the selection. An empty result now says so rather than rendering a
blank menu.
2026-07-28 03:27:46 +03:00
DumiFlex 630129bc32 feat(select): disambiguate same-labelled options with a meta column
A library routinely holds several wildcards under one display name — five
"Outfit" entries authored at different times — and the constraint source /
target dropdowns offered five identical rows with no way to tell which was
which.

Adds an optional `meta` to `SelectOption`, rendered dim and right-aligned,
and wires the constraint editor to pass "N opts · <short uuid>". The
type-to-filter now matches `meta` as well as `label`, so pasting a uuid jumps
straight to its row — without that the duplicates stay unreachable by typing,
since their labels are identical.

Also gives the option label `min-width: 0` + ellipsis so a long module name
clips inside the row instead of forcing the menu past its max-width and
pushing the meta and tick off the edge.
2026-07-28 03:23:42 +03:00
DumiFlex ba170939e8 feat(bundles): cap the expanded child list and summarise its kinds
A 66-child scene-composer bundle dumped every child inline on expand, so one
row buried the rest of the list.

Caps the rendered children at 8 behind a "Show all N" / "Show fewer" toggle,
and leads with a kind breakdown ("15 wildcards · 5 constraints", dominant
kind first) so a collapsed list still says what the bundle is MADE of rather
than only how many rows it has.

Expanded-in-full state is keyed by bundle id rather than a per-row flag, so
it survives re-sorting and re-filtering the list.
2026-07-28 03:13:47 +03:00
DumiFlex fd3c3805b6 feat(spa): add Report an issue + Discord to the sidebar
The Discord invite existed ONLY inside the community embed`s offline
fallback, so it was reachable exactly when the community failed to load and
invisible the rest of the time. There was no route to the issue tracker at
all.

Adds both to the Get Started section, with `GITHUB_NEW_ISSUE` joining the
link registry so a repo rename keeps touching one line. The issue link lands
on the chooser rather than a prefilled template — picking bug vs feature is
the reporter`s call, and a wrong prefill is more friction than none.

Also stamps `data-nav-id` on the external-anchor branch, which the button and
parent branches already carried.
2026-07-28 03:05:06 +03:00
DumiFlex 7cb2da2468 fix(chips): stop claiming every $var is "produced upstream"
The var-chip hover read `resolved ? "produced upstream" : "binds at
runtime"`, but for a var `resolved` is merely `name.length > 0` — deliberately
permissive in `atomIsResolved` so a legitimate runtime-bound var never renders
as a broken red chip. The upshot: EVERY var claimed an upstream producer.
Meaningless in the SPA, which has no graph at all, and unverified on the
canvas.

Adds an explicit `inScope` prop bound from the host`s suggestion pool
(upstream + siblings on the canvas, the library catalog in the SPA) and
reworded to "in scope" / "binds at runtime" — surface-neutral, and it claims
membership rather than production, which is the only thing the chip can
actually know.

`resolved` keeps its permissive meaning so chip STYLING is untouched.
`inScope` defaults to false, so a caller that passes no pool makes no claim
and falls back to "binds at runtime", which is always true.
2026-07-28 02:59:11 +03:00
DumiFlex aa1ae66821 fix(export): stamp schema_version on the copied engine row
Copy Payload wrote the bare engine row, which says nothing about which
payload shape it is in — and the shape is what decides whether a consumer
can read it. Publish already stamped the version (buildPublishBody, and the
publishToCommunity hash), so a copied row and the same row published to the
community carried different information about themselves.

Adds `buildCopyableRow`, leading the JSON with `schema_version` derived from
the SAME `schemaVersionForPayload` the publish paths use, so the two can
never disagree — including the feature bumps (SP2b range syntax, SP3
non-default constraint reach).

Note this is a single engine ROW, not an import envelope: `parsePayload`
wants `{schema_version, wildcards: [], bundles: [], categories: [], …}`, so
the clipboard JSON remains for inspection, sharing, and the community
publish form rather than for pasting into the Import tab.
2026-07-28 02:55:05 +03:00
DumiFlex f922a872cc feat(ui): make picker and reach-pick rows clickable, not just their checkboxes
Selecting a dozen entities out of a 261-item library meant a dozen precise
clicks on an 18px checkbox. Worse, the constraint reach pick rows already
carried `cursor: pointer`, so they LOOKED clickable and silently were not.

Both surfaces now toggle on a click anywhere in the row. Clicks originating
inside an interactive descendant are ignored, so the control and the row
never double-toggle and cancel each other out — the picker excludes buttons,
inputs, the checkbox wrapper and `.wp-id` (whose `cursor: copy` advertises a
different action), and the reach list excludes `.wp-check`.

Keyboard behaviour is deliberately unchanged on both: the checkbox stays the
single focusable control per row, so this adds no second tab stop for
keyboard or screen-reader users.

Replaces the TargetReachSection comment that documented the old
checkbox-only behaviour as intentional.
2026-07-28 02:44:40 +03:00
DumiFlex 23486ff1ef fix(injector): teleport the insert-ref menu and dismiss it on outside click
The menu was `position: absolute` inside the binding modal, so the modal`s
scroll container CLIPPED it — open it from a row near the bottom and the list
vanished below the fold. It also had no outside-click dismissal at all: once
open, the only exits were picking an item or closing the whole modal.

Teleports the menu to <body> with `position: fixed` viewport coords measured
off the trigger button, flipping above when there is no room below, matching
the strategy RichTextInput`s autocomplete popover already uses. Adds a
capture-phase mousedown listener that closes on any click outside both the
menu and its button, keeps the menu positioned across scroll/resize, and
tears every listener down on close and unmount.

Escape now dismisses the menu before falling through to cancel, so a stray
open dropdown can no longer cost the user their whole draft. The teleported
node mirrors the host`s resolved theme class, since it escapes the
`.wp-theme-*` scope the tokens hang off.

Tests query the teleported node through the document and unmount their
wrappers between cases — clearing document.body under a live component
detaches Vue`s teleport anchor and the next patch throws on a null parent.
2026-07-28 02:37:18 +03:00
DumiFlex 2fa4618ef9 fix(autocomplete): make the suggestion list actually scrollable
The popover capped matches at 8 rows. With 100+ wildcards sharing near
identical display names, a query like `pose` silently truncated to the first
8 and the rest were simply unreachable — and because 8 rows never overflowed
the popover`s 240px max-height, no scrollbar ever appeared. That reads to the
user as "the scrollbar does not work".

Raises the cap to 50 in both RichTextInput and VarAutocompleteInput so the
list genuinely overflows and `overflow-y: auto` earns its keep, and scrolls
the keyboard-selected row into view on every `acActive` change. Without the
latter, ArrowDown past the last visible row moved the selection but left the
popover scrolled at the top, so the highlight vanished and the list looked
frozen.
2026-07-28 02:33:13 +03:00
DumiFlex 50211e510b fix(autocomplete): keep the $/@ popover alive on partial brace refs
Typing the `{` that the canonical `@{uuid#name}` syntax calls for killed the
suggestion popover outright, and it only came back after deleting the entire
run — the reported "dropdowns break if you type something that cannot be
found and you need to delete the whole text and start typing again".

`probeAutocomplete` only scanned back through `[a-zA-Z0-9_]`, so `{`, and
every space or dash inside a display name ("Pose pool — presenting"), ended
the scan on a non-trigger char and returned null. The INSERT half of the same
feature already understood the form: `insertChipAtCursor`s cut regex allows
`\{[0-9a-fA-F:,_ -]*\}?`. The two halves simply disagreed about what a
half-typed ref looks like.

Probes the partial brace form first, before the bare-identifier scan. A `{`
not preceded by `@` stays an inline brace block (`{a|b|c}`), and a `}` already
present before the caret means the ref is complete so the popover stays shut.
2026-07-28 02:29:06 +03:00
DumiFlex d7a72d7434 fix(rich-text): remount contenteditable host when browser destroys its children
Alt-tabbing mid-token in the ComfyUI edit modal froze the page with
`DOMException: Node.insertBefore: Child to insert before is not a child of
this node`, followed by a cascade of null derefs (`emitsOptions of null`,
`parentNode of null`) that left the editor dead until a page refresh.

Root cause: the host is `contenteditable`, so the BROWSER owns its child
nodes and may replace them wholesale — select-all + retype, IME commit,
undo, some paste paths. Vue then holds detached `el` pointers in its child
vnodes. Blur fires a full re-parse via `onHostBlur`, and patching the new
atom structure over the wreckage derefs a dead node.

The existing `renderEpoch` rebuild does NOT cover this — verified against a
repro probe: bumping the v-for key still walks every stale `el` during
teardown and throws on `nextSibling` of null. The only safe repair is
discarding the host ELEMENT vnode, which Vue unmounts with a single
`hostRemove(el)` on a node that is still attached, tearing children down
with `doRemove: false` so no stale child is ever touched.

Adds `hostDomIsStale()` (live `[data-atom-index]` count below
`atoms.value.length`) plus a `hostEpoch` key on the host element. The check
is deliberately `<` and not `!==`: EXTRA top-level nodes are the normal
typing case and must not remount, which would eat the caret mid-keystroke.

Covers both directions with regression tests — the destroyed-children crash
and the orphan-text-node non-regression.
2026-07-28 02:12:07 +03:00
DumiFlex 7d443ba00b fix(context): guard bundle-snapshot reset on unlinked bundles
`resetChildToBundleSnapshot` fetched `api.bundles.get(bundle.library_id)`
without checking the bundle is actually linked. An unlinked or broken-link
BundleInstance carries an empty `library_id`, so the request built
`/wp/api/bundles/`, whose trailing slash normalizes to the collection
route — surfacing as `Reset failed: No such API route: /wp/api/bundles`,
which reads like a missing endpoint rather than a missing link.

Adds the same up-front guard its sibling `resetBundleToLibrary` already
had, and routes both catch blocks through `bundleResetErrorMessage` so a
404 (library row deleted or re-imported under a new uuid) names re-linking
as the recovery instead of leaking raw API text.
2026-07-28 02:07:57 +03:00
semantic-release-bot 1787b6041c chore(release): 2.11.0 [skip ci] 2026-07-24 02:07:07 +00:00
Dumitru Picalau fd4c501871 Merge pull request #11 from DumiFlex/fix/held-constraint-registration
fix(engine): hold + constraint correctness, and library re-link tooling
2026-07-24 04:06:31 +02:00
DumiFlex 9fb653f7ee docs(release-notes): summarise held-module fix + library re-link tooling 2026-07-24 04:56:13 +03:00
DumiFlex 3f45b26396 chore(size): raise total bundle budget to 461 KB
The chip hover cards and the self-contained Select styles push the canvas
bundle past the previous 453 KB ceiling. Entry budget is untouched (main.js
stays ~4 KB of the 30 KB allowance); this only moves the total.

Raised deliberately rather than trimming further — the remaining headroom is
~8 KB, so new canvas CSS/JS still needs to stay lean.
2026-07-24 04:48:49 +03:00
DumiFlex 44d0ca2261 fix(search): match modules, bundles and templates by id as well as name
Every search box was name-only, so pasting an 8-hex uuid — the one identifier
that is unambiguous when two entries share a display name — returned nothing.

Server side: the module, bundle and template `_build_where` clauses now match
`(name LIKE ? OR id LIKE ?)`. Client side: the remap-ref, bundle-reattach and
constraint-reattach pickers filter on uuid as well as name, which is the
reliable way to pick the right target among same-named candidates.
2026-07-24 04:43:59 +03:00
DumiFlex 699dedade0 feat(chips): hover card on nested-ref and variable chips
Hovering a `@{uuid}` chip now shows what it actually resolves to: the uuid,
the sub-category filter read back in plain language, and "N of M options
match" computed LIVE against the library via preview-resolver (extended with
per-option tag sets) rather than from a stale embedded copy — so the count
moves when the library changes. Variable chips show their binding state.

The popover teleports from INSIDE the chip span so the component keeps a
single root (a second root silently broke `wrapper.classes()` in every
existing RefChip test). "Not in library" is gated on the resolver actually
missing the uuid — constraints have no option tag sets, and keying off that
made valid entries claim they were missing.
2026-07-24 04:39:13 +03:00
DumiFlex a64e56d083 fix(chips): render nested refs + vars in derivation and exception surfaces
Nested `@{uuid}` refs and `$vars` inside `{a|b}` braces rendered as raw text
on the derivation rule-value and constraint-exception surfaces. Root cause was
hand-rolled `tokenizeRich` renderers dumping `dp-brace` verbatim — the shared
`atomicEditorModel.parse` already decomposes those, so both surfaces now
delegate to RichTextPreview instead of re-tokenising.

Exception "extras" also regain a real picker: a Select of concrete option
values with chipped #label/#option slots, rather than free text.

Select.vue ships self-contained styles for this: its `.wp-select*` rules lived
only in the manager-global tokens.css, which is NOT in the canvas bundle, so
on canvas the control rendered unstyled with its menu behind the modal. Any
manager UI component reused on canvas needs its own styles. Also pins rich
content to a single line inside the trigger/options, gives the SPA exception
table a fixed layout so nowrap cells can't overflow, and isolates the matrix
grid's stacking context so its sticky headers stop painting over the dock.
2026-07-24 04:34:24 +03:00
DumiFlex 5a83e61fd1 feat(editors): show module identity in edit-modal headers + wrap long values
Two debugging aids for the instance edit modals:

- InstanceIdChip: a click-to-copy monospace chip in every per-kind modal
  header showing the library uuid (the id that @{} refs, constraint
  source/target and library rows all match on), with the per-instance _uid
  in the tooltip. Makes drift/linkage problems traceable at a glance instead
  of requiring a workflow-JSON dig.
- RichTextInput gains a `wrap` prop so single-line surfaces wrap + auto-grow
  instead of clipping. Applied to the derivation rule override boxes, whose
  values were unreadable once they exceeded the field width.
2026-07-24 04:29:40 +03:00
DumiFlex c268e75c20 feat(import): surface content duplicates as a resolvable conflict row (D3b)
Completes D3b. The mechanism landed in 5dbe7c3c but nothing could produce a
`link` decision, because a content duplicate raises NO uuid collision and so
never reached ConflictModal.

- findLinkableDuplicates: the subset worth offering a link for — content
  matches an existing row under a DIFFERENT uuid AND the incoming id isn't
  itself a collision (that's the batch-conflict path's question).
- New per-item issue kind `content-duplicate` (+ ContentDuplicateDetail),
  built in ImportExport.buildPerItemIssues from the live library rows.
- ConflictModal renders it with a dim IN LIBRARY badge, names the existing
  entry, and offers "Link to existing" / "Import anyway". Never auto-applied:
  the row counts as unresolved until the user chooses.
- PerItemDecision gains kind "link" + target_id. partitionSelection drops a
  linked row like skip; runCommit folds old->target into the same remap table
  the renames use, so every @{} ref + constraint source/target follows to the
  existing entry instead of dangling on a uuid that never lands.
2026-07-24 04:19:47 +03:00
DumiFlex e63be54081 feat(import): link-to-existing decision for content duplicates (D3b core)
An incoming entity whose uuid does NOT collide still installs as a brand-new
row even when byte-identical content already exists under a DIFFERENT uuid.
Re-importing an iterated pack that way multiplies duplicate rows and splits
references across two identical entries.

Adds the mechanism:
- findContentDuplicates (pure): maps incoming id -> existing library id when
  EXACTLY ONE same-kind row shares the moduleFingerprint under a different
  uuid. Zero matches = genuinely new; 2+ = ambiguous, left to the user. A
  match at the SAME id is an ordinary collision classifyOne already handles.
- CollisionDecision gains { kind: "link", target_id }. applyCollisionDecisions
  drops the incoming entity and records old->target in the SAME renameMap the
  rename path uses, so the existing walkRemap pass re-points every @{oldId}
  ref + constraint source/target at the row that's already there.

Never automatic and never overwrites — import still defaults to create-as-new
(spec decision #2). Additive: with no producer of a `link` decision yet, every
existing path is unchanged. Surfacing it in the ImportPicker (content
duplicates raise no uuid collision, so they never reach ConflictModal) is the
remaining last-mile step.
2026-07-24 04:03:23 +03:00
DumiFlex ecebd55654 feat(relink): content-aware library re-link for detached bundle instances
Bundle-side sibling of the module re-link. A workflow BundleInstance is
detached when its library_id is absent from the polled bundleHashes map (a
re-import minted a fresh uuid). Its `inserted_at_hash` IS the library bundle's
payload_hash at insert time, so a content-identical library bundle under a
DIFFERENT uuid matches on exactly that — letting the bundle side reuse the
same pure findRelinkCandidates/autoRelinkTarget helpers as modules.

PushBundleToLibraryModal gains the re-link section (auto-picks a sole
identical match, else a ranked picker searchable by name or id, with
identical vs "content differs" badges) and emits mode:"relink".
ContextWidget.onBundlePushSaved folds "relink" into the existing rebind
branch, swapping library_id + inserted_at_hash + name and re-snapping the
fingerprint. No write happens — it points at an entry that already exists.
2026-07-24 03:38:10 +03:00
DumiFlex 78297c4179 fix(bundle): never let "Reset to library snapshot" silently empty a bundle
resetBundleToLibrary fetched the library snapshot and replaced the live
children unconditionally. When the instance's library_id was empty (an
unlinked/detached bundle) or the fetch resolved to an empty/mis-shaped
children array, it committed an EMPTY replacement — wiping the bundle's
modules with no warning (reported as "reset gave me an empty wildcard").

Two invariant guards, before the destructive commit:
- refuse when the instance has no library_id (not linked) — tell the user to
  re-link first, instead of fetching an empty snapshot;
- refuse when the fetched snapshot has no children (broken link / wrong shape)
  — abort rather than wipe the live children.

Both surface an error toast and leave the bundle untouched. Valid reset (a
linked bundle with a populated snapshot) is unchanged.
2026-07-24 00:17:36 +03:00
DumiFlex bb5f6b8658 fix(linkage): surface drift for a library-uuid module with an empty local hash
matchStateOf bailed on any falsy payload_hash, so a workflow module whose uuid
IS in the live library but whose local payload_hash is "" (a malformed/legacy
copy, or one a builder emitted with payload_hash: "") was verdict-less — no
drift dot, no "Edit library entry in SPA", and re-link couldn't fire either
(isLibraryMissing needs a truthy hash). The library link was never lost, only
the local hash was blank.

Now: when the uuid is present in libraryHashes, an empty local hash reads as
drift (conflict) so the existing bulk "refresh drifted" button relinks it in
one click. A genuinely inline module (fresh uuid, not in the library) stays
verdict-less exactly as before. All truthy-hash paths (missing/drift/silent-
skip/type-conflict) are unchanged, so D3a re-link's detached detection is intact.
2026-07-23 23:52:52 +03:00
DumiFlex a059b48635 feat(relink): content-aware library re-link for detached module rows
Surfaces a re-link path in PushToLibraryModal when a workflow module is
detached (library-tracked but its uuid is absent from the live library — a
re-import minted a fresh id) AND a content-identical library row exists under
a different uuid. Picks the sole identical row automatically, else shows a
ranked picker (identical vs "content differs", searchable by name or id).

Confirming emits mode:"relink"; both save paths apply it:
- right-click -> ContextWidget.onPushSaved: applyRelink swaps the row id +
  payload_hash and walks every module so sibling @{} refs + constraint
  source/target follow the library uuid.
- edit-modal -> ModuleEditModal stamps _relinkFrom (old id) before the swap;
  ContextWidget.saveEditedModule runs the same ref remap once committed.

Import stays create-as-new (no install.ts change) — re-link is the reconnect
path, never a silent overwrite. Bundle re-link is a separate follow-on.
2026-07-23 23:05:01 +03:00
DumiFlex 9ef1fb698a feat(relink): content-aware re-link match + apply helpers (pure)
Two pure helpers for D3a module library re-linking:

- relink-match: findRelinkCandidates ranks live-library rows against a
  detached draft by (type, payload_hash) content identity, then (type, name);
  autoRelinkTarget yields the sole content-identical row or null (ambiguous /
  none). Never auto-links on name alone.
- relink-apply: applyRelink swaps the detached instance's own id + payload_hash
  and walkRemaps every module's payload + instance so nested @{uuid} refs and
  constraint source/target follow to the new uuid. Reuses walkRemap verbatim.
2026-07-23 22:44:28 +03:00
DumiFlex 1b32a8a110 fix(engine): carry held modules' frame-0 constraint state to later frames
A held wildcard copies its frozen value from the pipeline hold base pass and
early-returns before _record_pick, so its frame-0 pick never landed in the real
ctx. Constraints keyed on a held source then mis-fired (spurious source_missing
/ never_applied warnings), and a cross-node constraint whose source is held left
its held target UNCONSTRAINED across frames (held outfit drifted: kimono at
frame 0, unconstrained pick on frames 2+).

Fix: after the per-node hold base pass, merge the base pass's __wp_picks__ (for
held modules) + __wp_constraint_hits__ into the real ctx, before the module
loop. The held source is then registered for this node's constraints and the
registration flows downstream into the next node's base pass, keeping cross-node
held targets constrained. Value-freeze path untouched.
2026-07-23 18:11:53 +03:00
semantic-release-bot 31b011b8fb chore(release): 2.10.2 [skip ci] 2026-07-21 22:16:47 +00:00
Dumitru Picalau a2e93fe7e4 Merge pull request #10 from DumiFlex/fix/updater-downgrade
fix: updater downgrade + pill reactivity + readable release notes
2026-07-22 00:16:09 +02:00
DumiFlex 1e42f9b5aa fix(manager): prevent updater downgrade, fix pill reactivity + release notes
Four fixes to the in-app updater shipped in 2.10.0:

- DOWNGRADE (critical): Update Now sent ComfyUI Manager selected_version:'latest' + mode:'cache', so 'latest' resolved against Manager's STALE cached catalog and could install an OLDER version over a newer one. Now pins the exact target version the check found (e.g. 2.10.1), uses mode:'remote' for a fresh catalog, and hard-guards against installing anything not strictly newer than what's running.
- Check Now now lights the topbar pill immediately. useReleaseCheck kept per-consumer refs, so a manual check in Settings never reached the topbar until reload; the reactive state is now a module-level singleton shared by every consumer.
- Release notes render properly: the renderer unwraps <details>/<summary>/<b> into markdown instead of showing literal tags (raw HTML still escaped).
- Changelog is human-readable: .releaserc.js now prepends a curated, plain-language highlights section (docs/release-notes/next.md) above the collapsed per-commit list, cleared automatically on each release.
2026-07-22 01:11:29 +03:00
semantic-release-bot 67199b5dba chore(release): 2.10.1 [skip ci] 2026-07-21 18:38:03 +00:00
Dumitru Picalau 74c7331b7e Merge pull request #9 from DumiFlex/fix/subcat-rename-validation
fix: validate sub-category & variable names on rename (#7)
2026-07-21 20:37:20 +02:00
DumiFlex 00b735d32f fix(manager): validate names on rename via shared validators (#7)
Sub-category rename hand-rolled a weaker check than the add path, so a name with a space (or other invalid char) slipped through the cascade rename dialog until save. Combine output-var renames weren't validated at all. Root cause: each name-entry surface reached for its own validator/regex and they drifted.

Add a single canonical validator module (src/manager/validation/names.ts) exposing validateSubcatName (re-exported from the engine-parity subcatFilter), validateRefGrammarName, and validateVariableName/isValidVariableName. Route every name-entry site through it: subcategory add+rename, wildcard/module display-name ref-grammar check (was triplicated inline), combine output_var save+rename, fixed-value var names, var binding, and validateModule. Add + rename now share the exact same function and cannot diverge again.
2026-07-21 20:56:48 +03:00
semantic-release-bot a1fe6956d1 chore(release): 2.10.0 [skip ci] 2026-07-21 17:31:59 +00:00
Dumitru Picalau fe2423f5b4 Merge pull request #8 from DumiFlex/feat/manager-qol
Manager QOL backlog + loop bypass frames + in-app update panel
2026-07-21 19:31:16 +02:00
DumiFlex 6c20f53cc6 feat(manager): add Updates settings card with check-on-launch + check now
Harden applyLatest against malformed/legacy cache blobs and a missing __APP_VERSION__ so a bad cache degrades to no-update instead of throwing; mock useReleaseCheck in the pre-existing Settings suite.
2026-07-21 20:19:31 +03:00
DumiFlex 936a13913e feat(manager): open UpdateDialog from the topbar update pill 2026-07-21 20:03:49 +03:00
DumiFlex 290e7a81f5 feat(manager): add UpdateDialog with changelog + Manager-driven update 2026-07-21 19:57:37 +03:00
DumiFlex 327e7cc762 feat(manager): add ComfyUI Manager update composable 2026-07-21 17:21:59 +03:00
DumiFlex 830776efd1 feat(manager): add safe markdown renderer for release notes 2026-07-21 17:17:05 +03:00
DumiFlex bdcf85ccd4 feat(manager): rework release check to launch-once + manual, expose release notes 2026-07-21 17:12:23 +03:00
DumiFlex d42b7ca1d8 feat(manager): add checkOnLaunch update preference to uiStore 2026-07-21 17:05:52 +03:00
DumiFlex fcde3b31d6 feat(manager): per-rule branch collapse + collapse rules/branches by default
- Each rule card gets inline collapse-all / expand-all buttons for its own
  conditions (IF/ELIF/ELSE branches), shown while the card is open.
- Branches now start COLLAPSED on open (newly-added branches still open
  expanded for immediate editing), and rules are collapsed by default
  regardless of count — so opening a derivation shows a compact overview.
2026-07-21 15:29:38 +03:00
DumiFlex ea4ed059c3 feat(loop): purple wash + dashed bar on bypassed seed rows 2026-07-21 15:13:52 +03:00
DumiFlex f670c3db76 feat(loop): mirror the loop's bypassed frames in the seed list UI
The WP_SeedList already emits only kept frames; now its widget shows WHICH.
The host glue reads the wired loop's bypass_frames (when Override count from
loop is on) and passes them to the widget: a "N bypassed" badge on the seeds
button + read-only dimmed/struck rows in the per-iteration modal (no toggle —
bypass is owned by the loop). Adds a `bypassReadonly` mode to the shared
SeedListModal so the same modal is interactive for the loop, read-only here.
2026-07-21 15:07:21 +03:00
DumiFlex af95f08ef4 fix(loop): seed list mirrors the loop's bypassed frames
A WP_SeedList wired to a bypassing loop still emitted the full `count`
seeds, so the sampler ran 18 times against the loop's 3 kept prompts.
WP_ContextLoop now publishes `kept_indices` (the 0-based frames it actually
emits) in its loop_config side output; WP_SeedList, when taking its count
from the loop (override_count), emits one seed per kept frame — drawn from
the full series so per-frame locks still land. Backward compatible: a
loop_config without kept_indices (or a full/empty list) leaves the emit
unchanged.
2026-07-21 14:49:26 +03:00
DumiFlex 70283ce4d3 feat(loop): dashed border on bypassed edit-frame chips 2026-07-21 14:40:32 +03:00
DumiFlex bab87cd3c2 fix(loop): parse parity + edge coverage from final review
Addresses the minor findings from the whole-branch review:
- TS bypass_frames parse now type-guards before accepting (no Number()
  coercion), so it drops true/"3"/null identically to the Python parser
  (the spec requires the two to agree on corrupt input).
- Add execute tests for bypassing frame 0 and for an out-of-range index
  re-applying once count grows past it (both spec-listed behaviors).
- Document seedAt's i>=1 precondition; pass :previous to inactive bypass
  rows so their "lock previous" button matches the active rows.
2026-07-21 03:06:20 +03:00
DumiFlex 7545ade87d feat(loop): wire per-frame bypass through the loop widget 2026-07-21 02:55:40 +03:00
DumiFlex 651ca65228 feat(loop): opt-in bypass column in the per-iteration modal 2026-07-21 02:48:52 +03:00
DumiFlex d524d142b5 feat(loop): add optional bypass toggle to seed-lock row 2026-07-21 02:43:13 +03:00
DumiFlex da2239dca5 feat(loop): mirror bypass_frames in the ts config type
Adds `bypass_frames: number[]` to ContextLoopConfig + its recovery parse
(sorted, deduped, non-negative ints, out-of-range kept — mirrors the Python
_parse_config). Bumps the js/ gzip budget 450 -> 453 KB: the config field
alone landed 34 bytes over 450, and the headroom covers the loop-bypass UI
added in the following tasks.
2026-07-21 02:38:07 +03:00
DumiFlex 9d372b1d8f feat(loop): emit only non-bypassed frames with pinned identity 2026-07-21 02:28:24 +03:00
DumiFlex 7aab09d6bc feat(loop): parse bypass_frames in the loop config 2026-07-21 02:21:09 +03:00
DumiFlex 2295ce0f81 fix(manager): count templates in the import loaded-bar total
`payloadEntityCount` summed seven buckets but omitted templates, so the
Import tab's "Loaded · N entities" bar under-reported any payload containing
templates (a template-only import read "0 entities"). Add the templates
bucket to the sum.
2026-07-21 01:42:08 +03:00
DumiFlex 8ea15719bd feat(manager): per-branch collapse + collapse-all for derivation rules (#9)
Building on the existing rule-card collapse (rules default-collapsed when
>1):

- Each IF / ELIF / ELSE branch now has its own collapse chevron. Collapsing a
  branch hides its condition/action editors and shows a one-line
  `$cond → $target` peek in the branch head, so a rule with many branches
  stays scannable.
- The Rules card gains Collapse-all / Expand-all buttons (shown when >1
  rule). They broadcast a `collapseCommand` (nonce + state) that every rule
  card adopts; individual cards can still be toggled afterwards.

Verified live: per-branch chevron hides the body + shows the peek;
Collapse-all / Expand-all toggle every rule card.
2026-07-21 00:21:50 +03:00
DumiFlex d03936478c feat(manager): move Target reach above the constraint rule matrix (#10a)
Section order is now Identity → Wildcards → Target reach → Rule matrix →
Exceptions. The "how many downstream targets does this constraint cover"
decision now reads before the per-pair rule grid, matching the top-down
authoring flow (pick wildcards → decide reach → fill the matrix).
2026-07-20 18:48:53 +03:00
DumiFlex 8dcef0f3d9 fix(manager): resolve inline multi-pick {N-M$$sep$$…} in the test runner
The JS resolver's `expandInlineChoices` only understood single-pick
`{a|b|c}`. A multi-pick block like `{1-2$$, $$smirk|smug|half-closed eyes}`
matched the brace regex but got split naively on `|`, so the `1-2$$, $$`
prefix stuck to the first branch and leaked into renders as raw
`1-2$$, $$smirk` (the Test Runner samples + histogram).

Mirror the engine's `_resolve_multi_pick` (engine/syntax/resolve.py):
detect the `N$$sep$$` / `N-M~$$sep$$` prefix, parse the count range, the
`~` independent flag (draw WITH replacement vs unique), and the separator;
then pick k weighted branches (via a `pickKUnique` port of the engine's
`pick_k_unique`) and join by sep. A fixed count skips the RNG draw, matching
the engine's seed behaviour. Single-pick `{a|b|c}` is unchanged.

Tests cover fixed/range counts, empty separator, independent-with-repeats,
and the exact user repro string.
2026-07-20 18:41:36 +03:00
DumiFlex 8912937a9a feat(manager): per-kind quick-select presets + import-tab mirror (#1)
Export quick-select bar now has a preset per kind (Bundles, Wildcards, Fixed
values, Combines, Derivations, Constraints, Categories, Templates) alongside
Full library + Favorites, via a generic `presetKindOnly` (replaces the lone
"Wildcards only"). Each per-kind button is disabled when the library has none
of that kind.

The Import picker now mirrors the bar after a payload loads from file/paste:
"All" + the same per-kind presets, operating on the loaded payload's
entities (disabled per empty bucket). The `.wp-preset-btn` atom moved to
tokens.css so both bars share it; each bar keeps its own layout wrapper.

Verified live: Export "Wildcards" → 56 of 132; Import "Wildcards" → 3 of 5,
"All" → 5 of 5, with empty-kind buttons correctly disabled on both.
2026-07-20 18:14:19 +03:00
DumiFlex d4e6d0c112 feat(manager): drag-and-drop file import (#5)
The Import tab now accepts a dropped .json export file, not just a file-pick
or paste. The pick/paste buttons sit inside a dashed dropzone that advertises
"or drag & drop a .json export file here"; dragging a file over it lights the
frame accent and switches the hint to "Drop to import"; dropping reads the
file and runs it through the same parse pipeline as the file picker (a shared
`readAndParse` helper). Non-JSON drops fall through to the existing inline
"Invalid payload" error, so there's no silent failure.

Verified live: the dropzone highlights on drag-over; unit tests cover the
drop→payload-ready path and the highlight toggle.
2026-07-20 17:57:48 +03:00
DumiFlex 696711dca1 fix(manager): fresh dates for imported adds (#12) + live counts after import (#7)
#12 — imported modules kept the exporter's original creation date (shown
"1mo old" instead of just-imported). The engine importer's insert paths use
`entity.get("created_at", now)`, so a client-supplied date is copied
verbatim. The rename path already stripped lifecycle fields for this reason;
extract that into a shared `stripLifecycle` helper and apply it to the `add`
path too, so the server stamps a fresh created_at (identity/`id` preserved).

#7 — the sidebar template + category count badges went stale after an
import, because the post-commit / post-undo refresh in ImportExport only
reloaded the module + bundle stores. Refresh `templateStore.fetchCatalog()`
and `categoryStore.fetchAll()` alongside them so the counts update live.
2026-07-20 17:31:03 +03:00
DumiFlex b54302d546 fix(manager): detect bundle collisions on import instead of blind-adding (#6)
Bundles were excluded from import collision detection on the premise that a
`bundle-fingerprint.ts` MOD-detection flow handled them downstream — but that
file was never built. So an imported bundle whose id already existed:
  - never appeared in the conflict modal (missing from the conflict count),
  - fell through to a blind `add` decision,
  - was rejected by the server with 400 "id collision on add",
  - which failed the whole commit and the bundles never imported.

Bundles live in the same id→snapshot_fingerprint library map as modules, so
route them through the SAME `detectCollisions`: identical → silent-skip,
present-but-changed / no library fingerprint → conflict / exists-unknown,
both of which surface in the conflict modal for Replace / Rename / Skip —
exactly like a colliding module. Fixed in `buildSelectedEntities`
(the commit orchestrator) and mirrored in `ImportPicker`'s inline badges so
an existing bundle reads MODIFIED / EXISTING / DUPLICATE / NEW consistently.
Removed the stale `bundle-fingerprint.ts` references from the comments.

Verified live: re-importing an existing bundle now opens the conflict modal
("1 existing" → Skip / Replace / Import as new) instead of 400-ing.
2026-07-20 17:15:42 +03:00
DumiFlex 0bd4382cf0 fix(manager): sticky-header subpixel bleed + confusing weight validation
Two polish fixes flagged before starting Batch C:

- Sticky header bleed: the editor scroll container's top can land on a
  fractional device pixel (measured 152.39 at dpr 1), so pinning the sticky
  region exactly at top:0 left a sub-pixel sliver above the opaque header
  through which the scrolled content behind showed as a faint ghost line.
  Overlap the scroll edge by 1px (top:-1px, clipped by the container overflow
  and absorbed by the header's 12px top padding) so the bg covers the seam at
  any DPR; the sticky <thead> tracks the same -1px to stay flush.

- Confusing weight tooltip: the option-weight input carried `min="0.01"
  step="0.1"`, so a default weight of 1 is off the native step grid and the
  browser popped up "Please select a valid value. The two nearest valid
  values are 0.91 and 1.01." The native step is validation-only here — arrow
  keys, the wheel, and the custom chevrons all route through bump(), which
  reads props.step itself. Input now emits native step="any" for number
  inputs (no grid validation), while bump() keeps stepping by props.step.
2026-07-20 16:43:32 +03:00
DumiFlex ed10b881b7 feat(manager): bulk select + delete for exceptions + derivation rules (#11)
Extends the shared useBulkSelection + BulkDeleteToolbar pattern to the last
two editors:

- ConstraintEditor exceptions (a table): a "Bulk edit" toggle adds a
  checkbox column + select-all to the editable exceptions table and a delete
  toolbar in the sticky subheader. Exceptions carry no persisted id, so
  selection keys off a WeakMap-backed transient uid (stable across edits and
  single-row removals, never written to the payload).

- DerivationEditor rules (a card list): the checkbox rides to the left of
  each rule card; select-all + the delete toolbar sit in the sticky
  subheader. Rules already have a stable id, so selection keys off it.

The bulk-edit toggle stays reachable while bulk mode is active even after
every row is deleted, so the user can always leave bulk mode.
2026-07-20 10:51:23 +03:00
DumiFlex cbf102b8a0 feat(manager): bulk select + delete for fixed-values (#11)
Adds a shared `useBulkSelection` composable + lean `BulkDeleteToolbar`
(count + Delete + Clear) for editors whose rows have nothing to bulk-set,
then wires them into FixedEditor: a "Bulk edit" toggle reveals a per-row
checkbox column + select-all, and the sticky subheader shows the delete
toolbar once ≥1 row is picked. Deletion is in-memory (marks the editor
dirty; committed on Save), and the existing bulk-ADD panel is untouched.
2026-07-20 10:36:34 +03:00
DumiFlex be70a5f149 feat(manager): relevant + colour-coded bulk sub-category menus
Bulk-edit Apply/Remove sub-category menus (#3, #4):
- Apply now lists only tags that would change something — every tag EXCEPT
  those already on all selected rows (commonTags).
- Remove now lists only tags the selection actually carries (presentTags,
  the union across selected rows); empty selection-tags → empty menu.
- Menu items show an axis-hue swatch via the shared `tagHue`, so a
  sub-category reads with the same colour here as in the pills / row chips.

Dropdown scroll-trap (#3):
- `overscroll-behavior: contain` on the menu list stops wheel scroll at its
  top/bottom edge from chaining to the editor body ("nudged the page").
- Scrolling the page behind an open menu now closes it (capture-phase scroll
  listener that ignores scrolls originating inside the menu's own list).

WildcardEditor computes commonSelectedTags / presentSelectedTags /
selectedTagHues and passes them to SelectionToolbar. New props are optional
and fall back to the old all-tags behaviour, so nothing else breaks.
2026-07-20 10:23:35 +03:00
DumiFlex 7d2bd63912 test(engine): un-skip pipeline trace tests after handler migration
The four tests skipped pending the tasks 15-17 handler migration now assert
the current dispatcher-based trace shape:
- writes always carry an `overwrite` flag (first write = False)
- disabled modules emit a `skipped_disabled` trace row (writing nothing)
- unknown module types emit a `skipped_unknown_type` row + a warning log
- rng is threaded via `ctx["__wp_rng__"]` (HANDLERS dict removed), seeded
  deterministically from the `seed` arg
2026-07-20 10:09:50 +03:00
DumiFlex 6f69304796 fix(manager): keep bulk-edit toolbar stuck with sticky card header
Batch A stuck only the card header; the bulk-edit toolbar (BulkAddPanel +
SelectionToolbar) lived below it in the card body as a separate flow
element, so scrolling a long option list left it behind. Wrap the header
and a new `subheader` slot in one `.wp-card__sticky` region and stick that
region as a unit. Card.vue now measures the whole region for
`--wp-card-header-h`, so the sticky <thead> auto-offsets below it as the
toolbar appears/disappears. Move the `.wpc-bulk-controls` block into the
subheader slot in WildcardEditor + FixedEditor.
2026-07-20 10:00:40 +03:00
DumiFlex 6802f11497 feat(manager): sticky list headers across editors
Long option / value / rule / exception lists scrolled their toolbar and
column header out of view, so "Add option" (and friends) were unreachable at
the bottom -- exactly where new rows land. Add a reusable `sticky-header`
option to Card: it pins the card header to the top of the editor scroll
region and publishes the measured header height (`--wp-card-header-h`, via
ResizeObserver) so a sticky <thead> can offset right below it. Chromium wont
scroll-anchor a table cell here, so the <thead> ELEMENT is stuck instead.

Applied to Wildcard options, Fixed-values, Derivation rules, and Constraint
exceptions. Verified live: header + column header pin, Add option reachable.
2026-07-20 00:10:16 +03:00
semantic-release-bot f888acdcd3 chore(release): 2.9.0 [skip ci] 2026-07-01 00:18:37 +00:00
DumiFlex 41fae1e41b fix(injector): keep custom pin labels with the socket on drag-reorder
reorderInjectorRows reassigned slot_name by position, but the object spread
carried the OLD slot_label along with the moved row -- so swapping two pins
left both rows showing the same stale label (the row that landed on a socket
did not pick up that socket's label). A custom label belongs to the physical
socket, which stays put on reorder (only the binding/template moves), so
snapshot socket->label before moving and assign each row the label of the
socket it lands on.
2026-07-01 03:17:58 +03:00
DumiFlex bf61f649fa fix(injector): right-align the type pill on label-less ref/slot rows
In the binding modal $ref / $slot dropdown, the type pill (e.g. "string")
sat next to the name on rows WITHOUT a label instead of right-aligned like
labelled rows -- the margin-left:auto lived on the label, which is v-if'd
out when absent. Give the type pill margin-left:auto too, and reset it to 0
when a label precedes it (the flex gap spaces them), so the pill lines up on
every row whether or not it has a label.
2026-07-01 03:17:58 +03:00
DumiFlex fc9d8a85a2 fix(derivation-modal): pin rule cards so the rule list actually scrolls
THE root cause of "rules clipped, no scrollbar": the rule cards are flex
children of the flex-column .rules__list, so they SHRANK to fit its
max-height and `.rule-card overflow:hidden` then clipped their branches. The
list never overflowed -> never scrolled -> no scrollbar ever appeared (each
rule showed only ~2 branches no matter how many it had). Add flex-shrink:0
to .rule-card so the cards keep full height; the list then truly overflows
calc(80vh - 20rem) and scrolls with its scrollbar, while header / identity /
runtime / footer stay pinned.

Verified live in ComfyUI via devtools: cards render ALL branches (clipped:
false), list scrollHeight 804 in a 336 cap (SCROLLS: true), modal does not
scroll, dock fully visible.
2026-07-01 03:17:58 +03:00
DumiFlex 6f664ce9e9 fix(derivation-modal): scroll the rule LIST, not the whole modal
After the last fix the MODAL scrolled (overflow-y:auto) while the rule list
did not -- and the sticky dock then covered the bottom rules on scroll, so
you still could not see the full rules. Force the scroll onto the rule list:
.dvm overflow:hidden (the modal cannot scroll, so header + identity stay
pinned at the top and the dock at the bottom) + the rule list capped at
calc(80vh - 20rem) (the modal budget minus the fixed chrome, padded so the
dock never clips) with overflow-y:auto. The rule list is now the only
scroller. Measured the chrome (~15rem) vs the budget in a devtools mock:
modal does not scroll, dock fully visible, list caps + scrolls.
2026-07-01 03:17:58 +03:00
DumiFlex 5d715fba99 fix(derivation-modal): absolute max-height for the rules scroll (revert flex)
My previous attempt used .dvm overflow:hidden + rules flex:1 1 auto, which
CLIPPED the rules with no scrollbar in the real app: the modal overlay
parents carry only max-height:100% (no definite height), so the flex-fill
child collapses instead of scrolling. A mock with a fixed-height container
masked it.

Match the wildcard modal's proven approach: .dvm overflow-y:auto + the rule
list capped with an ABSOLUTE max-height that scrolls in place. Cap is
calc(80vh - 16rem) -- the modal budget minus the fixed chrome (header +
identity + rules head + runtime/footer dock) -- so list + chrome never
exceeds 80vh: ONE scrollbar (the rule list), no outer modal bar, dock always
visible. Prominent scrollbar (12px, brighter than the subtle app default) so
it's unmistakably there.
2026-07-01 03:17:58 +03:00
DumiFlex e98aca30b3 fix(derivation-modal): scroll the rules region, pin header + footer
The derivation instance modal double-scrolled: the modal body (.dvm,
overflow-y:auto, max-height 80vh) AND the inner rule list (max-height 44vh)
both scrolled, so long rule lists clipped between the identity row and the
runtime/footer dock with no clear scrollbar -- "cannot see the full rules".

Make .dvm overflow:hidden and let the RULES region (flex:1 1 auto, content
basis so short lists still size naturally) fill the space and OWN the
scroll. Header + identity stay pinned at the top, the runtime/footer dock at
the bottom, and the rules scroll in between with a visible themed scrollbar
(matches the ModulePickerModal --wp-scrollbar-thumb convention).
2026-07-01 03:17:58 +03:00
DumiFlex df76abdbb0 fix(engine): derivation hold freezes each branch roll, not the branch choice
Hold was replaying the derivation's frame-0 OUTPUT, which froze WHICH branch
fired -- wrong. A derivation is a transform: the condition should still pick
the branch from the current inputs each iteration; only the random roll
inside a branch should freeze. Now hold resolves with the constant hold seed
keyed PER BRANCH (rule:branch), so each branch's inline {a|b} / @{} value is
frozen to its frame-0 roll independent of which other branches fired -- but a
varying input still switches IF->ELIF and uses THAT branch's frozen value
(== what that branch would have rolled at frame 0).

Drops the now-unused __wp_module_outputs__ capture from pipeline.run (the
freeze-output replay was its only consumer). wildcard / combine /
fixed_values still freeze the whole resolved VALUE via __wp_hold_base_ctx__,
because a constraint can reshape their pool so a frozen seed isn't enough --
a derivation branch's pool is fixed, so per-branch seed freeze suffices.
2026-07-01 03:17:58 +03:00
DumiFlex 8568aa2c1e fix(injector): persist custom pin labels durably across collapse + reload
The last fix reverted renamed pins to input_N on expand: the only copy of
the custom label lived in litegraph slot.label, which collapse overwrites
with a placeholder ("inputs xN" / " ") and serializes. So the label was
lost on reload, and the collapsed row tag even showed "inputs xN".

Persist it durably instead (new InjectorRow.slot_label). Capture the live
pin labels into the row on collapse -- the last tick they're readable --
and have the collapse module's expandedLabel reconstruct from it on expand
(replacing the () => undefined name-fallback). computeSlotLabels no longer
surfaces the placeholder while collapsed, and the row tag prefers
row.slot_label over the live slotLabels, so a collapsed / reloaded node
shows the real label, not the placeholder.

Existing already-corrupted saves can't be recovered (the label was
overwritten before this code ran) -- rename once + collapse to re-capture.
2026-07-01 03:17:58 +03:00
DumiFlex aba4e8c8ac docs(spa): note derivation supports Hold across run
Add derivation to the Hold-capable kinds in the Seeds & loops page and
describe its output freezing even as inputs vary.
2026-07-01 03:17:58 +03:00
DumiFlex ff6825cedd feat(engine): derivation honors seed_scope=hold (freeze frame-0 output)
A derivation is a transform that writes a DYNAMIC set of vars (whichever
rules fire), so it can't reuse the single-binding hold path. The pipeline
now captures each module's frame-0 output dict keyed by per-instance uid
during the hold base pass (__wp_module_outputs__); the derivation handler
replays its captured dict verbatim on hold. The captured values are
post-resolve, so inline {a|b} picks AND nested @{} refs freeze too -- and
the output stays frozen even when the derivation's INPUT varies (the same
value-freeze trade-off a constrained wildcard makes).

Adds derivation to HOLD_KINDS + a "Hold across run" toggle on the
derivation RuntimeSection. The row right-click Hold item picks it up for
free via HOLD_KINDS.
2026-07-01 03:17:58 +03:00
DumiFlex 0ede205530 fix(injector): reconstruct collapsed-wire labels from data, not the dead stash
The collapse label stash is an in-memory WeakMap that does not survive a
reload. A node saved collapsed serialized the placeholder ("inputs xN" /
" ") into slot.label, so on reload the stash was empty and expand restored
the placeholder onto input_0/1/2 instead of the names. The earlier
pre-attach reset ran BEFORE litegraph configure() restored the
placeholders, so it was a no-op -- which is why the bug persisted.

Add an opt-in expandedLabel resolver to attachCollapsableConnections: in
that mode the module skips the stash and reconstructs each matched slot's
expanded label from durable data on expand -- for EVERY matched slot,
stashed or not, which is what heals a reloaded node. The injector passes
() => undefined since its pins are name-derived (input_N). Existing broken
saves self-heal on the next expand.

Tradeoff: a custom pin label set via the slot context menu no longer
survives a collapse cycle (reverts to input_N) -- it was never persisted
durably anyway, so it was already lost on reload.
2026-07-01 03:17:58 +03:00
DumiFlex a738618dd3 docs(spa): mention the right-click Hold toggle shortcut
The Hold sections described only the Runtime-settings path; add the
two-click row context-menu shortcut alongside it on the WP Context node
page and the Seeds & loops concept page.
2026-07-01 03:17:58 +03:00
DumiFlex 8dcebdcc55 feat(context): quick Hold toggle in the module row context menu
Right-click a wildcard / combine / fixed-values row and "Hold across
run" now flips instance.seed_scope without opening the editor (4 clicks
to 2). The item swaps to "Release hold" when held, mirrors the editor's
base-only rule (disabled with a hint while a loop edit-frame is active),
and is gated to HOLD_KINDS so derivation/constraint rows -- whose
handlers ignore seed_scope -- never see it.

Bumps the extension bundle ceiling 449 -> 450 KB (measured 133 B over).
2026-07-01 03:17:58 +03:00
DumiFlex 1b3e4aa61f docs(spa): describe hold as a value freeze (constrained picks + nested refs) 2026-07-01 03:17:58 +03:00
DumiFlex 0281b8e321 fix(injector): repair collapse-wire labels corrupted across a reload
An older build serialized the collapse PLACEHOLDER labels ('inputs xN' on the
first input pin, ' ' on the rest) into the workflow JSON. On reload the slots
are fresh objects, so applyCollapsedLabels stashed those placeholders as the
'original' label — and expanding then restored the placeholder instead of the
real name (input_0 -> 'inputs xN', siblings blank, input_3 dangling).

The injector's input pins are always name-derived (input_N), so clear any
persisted label before attachCollapsableConnections runs, making the stash
capture the correct name-fallback original. Self-healing on every load, so
existing corrupted saves recover too. Regression tests in
collapse-connections.test.ts cover the repair contract + the bug it guards.
2026-07-01 03:17:58 +03:00
DumiFlex 58c5b27922 fix(engine): hold the resolved VALUE across iterations, not just the seed
seed_scope=hold previously swapped in a constant seed and re-picked each
iteration. That failed two ways: (1) a held wildcard whose pool is reshaped by
a constraint (varying source) picks a different option from the same seed; and
(2) under Override seed, the 'hold' seed equalled the per-iteration derived[k]
(effective_chain_seed returns an override verbatim, ignoring loop_index=0), so
hold was a no-op. Nested @{} refs in a held value also re-rolled.

Now hold freezes the frame-0 RESOLVED value. context_node computes a constant
hold seed from the loop's derived-series head; pipeline runs a throwaway base
pass at loop_index=0 when a loop drives + any module holds, and the
wildcard/combine/fixed-values handlers copy their resolved value from that base
context verbatim — skipping the pick AND resolve_text, so a constrained or
@{}-bearing held wildcard stays frozen (green jeans stays green jeans).

Tests: constrained held wildcard + nested-ref held wildcard each stay constant
across iterations while their vary counterparts move.
2026-07-01 03:17:58 +03:00
DumiFlex 4a34c9bbdd docs(spa): document per-iteration overrides, seed scope + lock-previous
Adds a 'Per-iteration overrides' concept page (edit-frame, per-frame
override/enable/seed-lock) and expands seeds-and-loops + the WP Context Loop /
WP Seed List / WP Context node pages with seed scope (vary/hold), the
per-iteration seed modal, and the lock-previous button. Mirrors the wiki
update. Held off main so it doesn't cut a standalone release.
2026-07-01 03:17:58 +03:00
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
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
semantic-release-bot fe0d702e75 chore(release): 2.6.0 [skip ci] 2026-06-22 15:49:55 +00:00
DumiFlex d3e25b72ad fix(constraint): drop matrix scroll-container padding that leaked scrolled cells
The real bleed source (the border-collapse fix was necessary but not
sufficient): the scroll frame had `padding: 6px`. position:sticky pins the
frozen headers at the padding edge, but overflow still renders the scrolling
cells INSIDE that padding — so a 6px strip of scrolled cells stayed visible on
the top/left scroll-start sides, just outside the sticky row/column headers.

Live-diagnosed on the SPA editor: exaggerating the padding to 30px grew the
bleed strip to 30px; setting it to 0 removed it entirely. Set padding:0 on both
scroll containers (SPA ConstraintMatrix + canvas MatrixSection); the frame's
1px border + border-radius give the visual edge, content sits flush, and the
4px inter-cell gap (th/td padding) is unaffected.
2026-06-22 17:51:49 +03:00
DumiFlex 40980093e8 fix(constraint): stop scrolled cells bleeding through the frozen matrix headers
position:sticky header cells under border-collapse:collapse hit a Chromium
paint bug: scrolled data cells show through the frozen row/column headers
instead of being covered. Switch both matrices (SPA ConstraintMatrix + canvas
MatrixSection) to border-collapse:separate with border-spacing:0; the 4px
inter-cell gap still comes from the th/td padding, so the layout is unchanged
while the sticky backings now paint opaquely.
2026-06-22 17:10:38 +03:00
DumiFlex 538db06efc feat(constraint): group the canvas matrix axes like the SPA
The ComfyUI instance-modal matrix (MatrixSection) was still flat while the SPA
ConstraintMatrix had grown grouped axes. Bring it to parity:

- Extract the pure grouping logic (order-by-axis, contiguous groups, bucket
  detection, labels, per-axis hue) into a shared components/shared/matrix-axis
  module so the two grids stay identical; refactor the SPA matrix to import it.
- Port MatrixSection: column bands name each target axis, row header chips name
  each multi-tag source axis, a solo axis folds its name into an eyebrow, and
  the leftover run is labelled "uncategorized" (neutral, dashed) — not blank.
  Headers + tag column + group chips + corner freeze while the grid scrolls on
  both axes. All per-instance cell logic (overrides, popover, stranded) is
  unchanged; cells stay keyed by tag name so the reorder is safe.
- ConstraintInstanceModal threads each wildcard's tag_groups into the matrix.
  Cross-context refs resolved only via the preview-resolver cache (which has no
  tag_groups) degrade to flat — the common sibling/chain case groups.
- Bump the extension bundle budget 432 -> 435 KB for the ~1.1 KB net growth.
2026-06-22 15:27:08 +03:00
DumiFlex eac852fbda feat(manager): label the matrix ungrouped axis run as "uncategorized"
The leftover run of sub-categories with no axis (or an empty axis name)
rendered as a blank band over its columns and no chip above its rows — it
read as a layout gap. It now gets the same treatment as a real axis: a
band ("uncategorized") over the columns and a header chip over the rows,
tinted neutral grey (distinct from the per-axis palette) with a dashed
edge so it still reads as the catch-all bucket rather than a named axis.

Gated on the matrix having at least one named axis: a fully-flat matrix
(no tag_groups at all) keeps the legacy source/target-tinted flat headers
and grows no "uncategorized" label. Ordering is unchanged — only the
bucket's label + colour change.
2026-06-22 14:49:19 +03:00
DumiFlex 0bbf150ca3 feat(manager): show uuid + option meta in reattach pickers to tell duplicates apart
Same-named library entries were indistinguishable in the broken-reference
pickers (two "mood", two "color" with nothing to choose between). Each
candidate now surfaces the disambiguating data:

- Constraint reattach: 8-hex uuid + option count + declared sub-categories.
- @{} remap popup: 8-hex uuid alongside the existing option count.
- Bundle child reattach: 8-hex id beside the name.

The uuid is the definitive tiebreaker; the option/sub-category line gives a
human-readable one when the hex alone is opaque.
2026-06-22 14:14:05 +03:00
DumiFlex c0f354cb8f feat(manager): group constraint matrix axes with header chips and column bands
Redesign the constraint matrix headers so each sub-category axis reads as
one coloured group on both axes:

- Columns grow a band row naming each axis, spanning its tags; flat
  (ungrouped) columns keep the single header row.
- Multi-tag row groups get a pinned header chip (left-frozen, full axis
  name always readable however long); solo grouped axes fold the name in
  as an eyebrow over the value instead of wasting a header row.
- Per-axis hues come from the shared axisHueAt palette; ungrouped tags
  keep the legacy source/target tint.
- Column band, tag column, group chips and corner stay frozen while cells
  scroll on both axes; the tag row sticks at the measured band height.

ConstraintEditor passes the source/target wildcard tag_groups through so
the matrix can order and colour by axis.
2026-06-22 13:47:17 +03:00
DumiFlex b3537ad6c5 feat(manager): type-to-filter all dropdowns
While a Select menu is open, printable keystrokes build a query that filters options (case-insensitive substring); Backspace trims, a "Filtering:" row shows the query, and an empty result shows "No matches". Arrow/Enter navigate the filtered set. Small dropdowns look unchanged until you type, so every Select gains autocomplete with no per-call-site change.
2026-06-22 02:17:27 +03:00
DumiFlex a77e60b402 feat(manager): author and save bundles directly in the manager
The bundle editor hard-disabled Save unless editing an existing bundle, so a new bundle could be opened but never saved ("create from Context" only). Save is now gated on validation (a name) for both modes; in create-mode it POSTs a new bundle and routes into its edit page. The add-child picker already builds save-valid children, so authoring here is safe.
2026-06-22 02:12:56 +03:00
DumiFlex c1f45a3fd2 fix(api): keep duplicate children when creating or updating bundles
Bundle children were deduped by id on create + update, silently dropping the second+ copy — but multi-instance bundles repeat a module id on purpose (one wildcard used as garment/hair/eyes). Removed _dedupe_children from both paths; per-instance disambiguation happens at Context-insert time via _uid, not at bundle-store time. Inverts the former QA-hardening dedup tests to assert preservation.
2026-06-22 02:08:26 +03:00
DumiFlex 11d205bf61 fix(manager): decimal option probabilities and distinct sub-category group colors
Option probabilities rounded to "0%" for anything below 0.5% — misleading when a wildcard has many options. New utils/percent.ts formatProbability keeps large values as integers and grows decimals for small ones (0.01%) so a nonzero weight never reads as zero.

Sub-category group colors repeated: the per-component AXIS_HUES palette held two near-identical purples and wrapped with % length. Extracted one shared components/shared/axis-color.ts (axisHueAt) with a de-duplicated, order-separated palette plus a golden-angle fallback so every group is distinct; OptionRow, PoolSection, and WildcardEditor now share it.
2026-06-22 02:04:04 +03:00
semantic-release-bot d628a0687d chore(release): 2.5.0 [skip ci] 2026-06-18 04:36:29 +00:00
DumiFlex 02cc9f766f feat(manager): one-time library repair for non-hex module/bundle ids
Pre-existing ids that aren't 8-hex (e.g. an imported 'coloruni') can't be nested-referenced. A version-gated SPA-boot repair (runLibraryRepair, fired fire-and-forget from AppLayout) rekeys them. create() server-mints ids, so it creates copies -> builds the old->new map -> remaps every referrer (nested @{} via a literal pass since walkRemap's REF_RE is 8-hex only, plus whole-string constraint axes) -> updates -> deletes the old rows, ordered create->update->delete so a crash never leaves a dangling ref. Pure planning (findInvalidIds / planRemapUpdates / remapAll) unit-tested; orchestrator tested against a mock store.
2026-06-18 07:31:07 +03:00
DumiFlex a1d051172d fix(import): normalize non-hex module/bundle ids on import + download
A module/bundle id that isn't a canonical 8-hex uuid (e.g. a hand-authored 'coloruni' that round-tripped through import -> community -> download) silently can't be nested-referenced: @{coloruni} matches neither the editor chip tokenizer nor the engine resolver (both require [0-9a-f]{8}), so the ref renders as plain text and never resolves. install.ts now mints a fresh valid id for any invalid module/bundle id (new isValidId in utils/ids.ts) and folds it into the reattach rename map, so walkRemap rewrites every @{oldid} ref + constraint source/target axis to the new id. Covers both import and community download (same install path).
2026-06-18 07:00:09 +03:00
semantic-release-bot 5b5c635be6 chore(release): 2.4.0 [skip ci] 2026-06-18 02:53:08 +00:00
Dumitru Picalau ccb8392219 Merge pull request #4 from DumiFlex/feat/inline-weights-pool-ui-debug
fix: inline branch weights + wildcard pool UI + debug warnings
2026-06-18 05:52:28 +03:00
DumiFlex d8c20728cb fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs
Three pool/UI fixes. (B) The MOD dot now lights on filter / pick-range / exclude-null changes — the category_filter check ran nonEmptyArr() on a string and silently missed, so toggling the pool filter never marked the instance modified. (C/D) The wildcard card subtitle shows 'X of Y options' when a filter narrows the pool, and '· picks N[-M]' when the instance multi-picks. (F) A nested @{uuid} ref to a live-library wildcard not in the chain no longer shows a false BROKEN REF — the Context node DB-expands its catalog at run, so it resolves; only refs absent from BOTH chain and library still flag (and those already emit unknown_ref in the debug panel). Bumps the extension bundle budget 431->432 KB.
2026-06-18 05:42:34 +03:00
DumiFlex 0e9e5db486 feat(engine): emit a constraint source-missing debug warning
A constraint with both ends absent from the chain emitted only the target-focused constraint_never_applied warning, so WP Debug showed one issue while the editor row flagged both source AND target missing. The pipeline now also emits constraint_source_missing (with a matching DebugViewer label) when the constraint's source wildcard isn't in the chain, so both ends surface in the debug panel.
2026-06-18 05:37:55 +03:00
DumiFlex a9516996b4 fix(engine): apply N:: branch weights in simple inline {a|b|c} choices
Simple inline picks resolved a branch uniformly and never stripped the N:: weight prefix, so {2::a|b} could emit the literal 2::a and {2::|, long shadows} leaked 2::. _resolve_inline_pick now strips the prefix and weights the pick when any branch carries one, staying byte-for-byte seed-stable for unweighted choices. Mirrored in the TS preview resolver (expandInlineChoices); multi-pick blocks already handled weights.
2026-06-18 05:33:24 +03:00
semantic-release-bot 02a046321f chore(release): 2.3.1 [skip ci] 2026-06-18 01:06:25 +00:00
Dumitru Picalau 6a5a8d318e Merge pull request #3 from DumiFlex/fix/instance-modal-scroll
fix(extension): scroll instance edit modals with pinned header + footer
2026-06-18 04:05:45 +03:00
DumiFlex a19e5f4500 Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll
# Conflicts:
#	scripts/check-bundle-size.mjs
2026-06-18 03:44:54 +03:00
DumiFlex c43bff0488 fix(extension): scroll instance edit modals with pinned header + footer
Every per-instance edit modal (wildcard, fixed-values, derivation, combine, constraint, bundle) now scrolls its body when content overflows, with the header pinned to the top and a sticky dock pinned to the bottom (runtime + footer, or target-reach + footer for constraints, which have no runtime) so Save is always reachable. The long inner lists — options, values, rules, exceptions — each scroll independently with sticky column headers. Constraint's target-reach moves to last. Compact + right-padded the exceptions delete button. Bumps the extension bundle budget 430->431 KB for the added modal CSS.
2026-06-18 03:23:24 +03:00
786 changed files with 92038 additions and 10771 deletions
+5 -1
View File
@@ -1,8 +1,12 @@
name: CI
on:
# Every branch, not just main. The pre-commit hook now scopes its checks to
# the files that changed (see .husky/pre-commit), which is only safe because
# the full suite runs here on every push — previously a feature branch got no
# remote verification at all until a PR was opened.
push:
branches: [main]
branches: ["**"]
pull_request:
branches: [main]
+82
View File
@@ -0,0 +1,82 @@
name: Discord announce
# Two channels, deliberately asymmetric.
#
# staff — posts AUTOMATICALLY when semantic-release publishes a release.
# The post carries a link back to this workflow, so the review and
# the publish button are the same artefact.
# public — only ever from a manual run. A release can never announce itself
# to everyone without someone having read it first.
#
# Deliberately a SEPARATE workflow from release.yml: a Discord outage, a
# rotated webhook or a rate limit must never fail a release that already
# shipped. Re-run this job instead.
on:
release:
types: [published]
workflow_dispatch:
inputs:
channel:
description: "Where to post."
required: true
type: choice
default: staff
options: [staff, public]
since:
description: "Roll up every release after this tag (e.g. v2.10.2). Blank = latest release only."
required: false
type: string
dry_run:
description: "Print the message instead of posting it."
required: false
type: boolean
default: true
permissions:
contents: read
jobs:
announce:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: "22"
# setup-node sniffs the lockfile and tries to prime a pnpm cache,
# which fails because pnpm is never installed here. It is not needed:
# the announce script has no dependencies at all — plain ESM and the
# global fetch built into Node 18+. Nothing is installed, so there is
# nothing to cache.
package-manager-cache: false
# A `release` event always posts, and always to staff. A dispatch honours
# both inputs, with dry_run defaulting to true so a manual run cannot
# surprise anyone.
- name: Announce
env:
DISCORD_WEBHOOK_STAFF: ${{ secrets.DISCORD_WEBHOOK_STAFF }}
DISCORD_WEBHOOK_PUBLIC: ${{ secrets.DISCORD_WEBHOOK_PUBLIC }}
# Kept so an existing single-webhook setup keeps working as staff.
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_ROLE_ID: ${{ secrets.DISCORD_ROLE_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
ARGS=""
if [ "${{ github.event_name }}" = "release" ]; then
ARGS="--version ${{ github.event.release.tag_name }} --channel staff --post"
else
ARGS="--channel ${{ inputs.channel }}"
if [ -n "${{ inputs.since }}" ]; then
ARGS="$ARGS --since ${{ inputs.since }}"
fi
if [ "${{ inputs.dry_run }}" = "false" ]; then
ARGS="$ARGS --post"
fi
fi
echo "Running: discord-announce.mjs $ARGS"
node scripts/discord-announce.mjs $ARGS
+19
View File
@@ -3,6 +3,25 @@ name: Release
on:
push:
branches: [main]
# Tooling-only pushes must not be able to cut a release, whatever their
# commit type says.
#
# 2.13.0 shipped as an empty release because three commits touching only
# `scripts/` and `.github/` were typed `feat(announce)` / `fix(announce)`.
# Both are release-triggering, so semantic-release did exactly what it was
# told — every user on 2.12.0 was offered an update with a blank changelog.
# Relying on remembering to type those `ci:` had already failed once.
#
# Neither path is part of what a user installs: `scripts/` is dev tooling
# and `.github/` is CI. GitHub skips the run only when EVERY changed file
# matches, so a push that touches shipped code alongside a workflow still
# releases correctly.
#
# `docs/**` is deliberately NOT here — `docs(readme)` is configured to bump
# a patch, and `docs/release-notes/next.md` is the release body itself.
paths-ignore:
- 'scripts/**'
- '.github/**'
permissions:
contents: write
+101
View File
@@ -0,0 +1,101 @@
name: Build tag list
# MANUAL ONLY, and deliberately so.
#
# The list is not tied to our release cycle — it describes Danbooru, not this
# project — and it goes stale slowly. Verified 2026-08-06: a roughly two-year-old
# tag file still autocompletes correctly, because staleness moves post counts
# (and therefore ranking) while tag NAMES barely change. There is no correctness
# argument for a schedule, only a ranking one, and that does not justify a timer
# pointed at someone else's API.
#
# So: run it when you want a refresh — a new character tag everyone is asking
# for, or once in a while out of tidiness. The asset stays put until then.
#
# It publishes to a FIXED tag, replacing the asset in place, so the download URL
# never changes:
#
# https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/releases/download/tag-list/wildcard-pipeline-tags.csv
#
# That stability is the point: the SPA's "download tag list" button hardcodes
# that URL and never needs updating. A per-run tag would break it every time.
#
# The tag is intentionally not semver, so semantic-release ignores it and this
# never looks like a product release.
on:
workflow_dispatch:
inputs:
min_count:
description: "Stop once tags drop below this many posts. 20 ≈ 150k tags; 68 ≈ the 100k other extensions ship."
required: true
default: "20"
type: string
alias_pages:
description: "Pages of aliases to fetch (1000 each). Aliases are what make `hires` find `highres`."
required: true
default: "60"
type: string
publish:
description: "Attach the result to the tag-list release. Off = build it and keep it as a run artifact only."
required: false
default: false
type: boolean
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# No dependencies on purpose: the builder uses urllib from the standard
# library, so there is nothing to install and nothing to keep patched.
- name: Build
run: |
python scripts/build_tag_list.py \
--min-count "${{ inputs.min_count }}" \
--alias-pages "${{ inputs.alias_pages }}" \
--out wildcard-pipeline-tags.csv
- name: Summarise
run: |
{
echo "### Tag list built"
echo
echo "| | |"
echo "|---|---|"
echo "| Tags | $(wc -l < wildcard-pipeline-tags.csv) |"
echo "| Size | $(du -h wildcard-pipeline-tags.csv | cut -f1) |"
echo "| Floor | ${{ inputs.min_count }} posts |"
echo "| Published | ${{ inputs.publish }} |"
} >> "$GITHUB_STEP_SUMMARY"
# Always kept, so a build can be inspected before it replaces the asset
# everyone downloads.
- uses: actions/upload-artifact@v4
with:
name: wildcard-pipeline-tags
path: wildcard-pipeline-tags.csv
retention-days: 14
- name: Publish to the fixed tag-list release
if: ${{ inputs.publish }}
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# `--clobber` replaces the asset in place, which is what keeps the
# download URL stable across refreshes.
if gh release view tag-list >/dev/null 2>&1; then
gh release upload tag-list wildcard-pipeline-tags.csv --clobber
else
gh release create tag-list wildcard-pipeline-tags.csv \
--title "Booru tag list" \
--notes "Autocomplete data for ComfyUI-Wildcard-Pipeline. Rebuilt on demand; not a product release. Download it from the manager's Settings page rather than by hand." \
--latest=false
fi
+14
View File
@@ -52,13 +52,27 @@ Thumbs.db
/docs/*
!/docs/help/
!/docs/import-export-contract.md
# Network behaviour + download hardening, linked from the README
!/docs/network-access.md
# Curated release highlights — read by .releaserc.js at release time, so it
# must be tracked (the release step commits the cleared file back).
!/docs/release-notes/
# Semantic release
.releaserc.local.js
# Throwaway design / scratch artifacts (mockups, screenshots, demos)
/_temp/
# Root-level PNGs are always scratch QA screenshots here — legit images live
# under public/. Anchored so it never touches public/images/**. Keeps stray
# captures out of git and out of pack-release's ship-list gate.
/*.png
# DB location control — per-contributor sidecar config + root-location DB dir
db-config.json
wp-settings.json
/db/
.tmp-check/
# Playwright MCP session artifacts (page snapshots, console logs).
.playwright-mcp/
+64 -14
View File
@@ -1,18 +1,68 @@
# Lint + typecheck + frontend tests
pnpm lint || exit 1
pnpm typecheck || exit 1
pnpm test || exit 1
# Pre-commit gate, scoped to what actually changed.
#
# This used to run the whole CI suite on every commit — lint, typecheck, the
# full 4000-test vitest run, a build, the size gate, ruff and the full pytest
# suite — about 4.5 minutes regardless of whether the commit touched one Vue
# file or one markdown file. CI (.github/workflows/ci.yml) now runs on every
# push, not just main, so the local hook's job is to catch mistakes fast rather
# than re-prove the whole tree.
#
# Measured on this machine:
# lint 11s · typecheck 18s · full vitest 150s · build 10s · size 2s
# ruff 0s · full pytest 76s · vitest scoped to changed files ~30s
#
# So a frontend-only commit drops from ~4.5min to ~70s, a Python-only commit
# skips the JS half, and a docs-only commit exits immediately.
#
# Escape hatch: `WP_FULL=1 git commit …` runs everything, unscoped.
# Bundle size budget — build the extension chunk and run the size check so
# regressions get caught locally before the CI flags them on PR. Skips when
# js/ output already matches the source by mtime to keep repeat commits fast.
pnpm build:extension || exit 1
pnpm size || exit 1
staged=$(git diff --cached --name-only --diff-filter=ACMR)
[ -z "$staged" ] && exit 0
# Python checks — only if ruff/pytest reachable on PATH
if command -v ruff >/dev/null 2>&1; then
ruff check . || exit 1
has() { echo "$staged" | grep -qE "$1"; }
FRONT=0
PY=0
# Anything that can change the bundle or its types.
has '^(src/|scripts/|package\.json|pnpm-lock\.yaml|vite\.|tsconfig|eslint)' && FRONT=1
# Anything the Python suites cover.
has '(\.py$|^(engine/|wp_nodes/|wp_api/|tests/|pyproject\.toml))' && PY=1
if [ "$WP_FULL" = "1" ]; then
FRONT=1
PY=1
fi
if command -v pytest >/dev/null 2>&1; then
pytest -q || exit 1
if [ "$FRONT" = "1" ]; then
pnpm lint || exit 1
pnpm typecheck || exit 1
# Only the tests reachable from the staged sources. `vitest related` walks
# the import graph, so a change to a shared module still pulls in every
# dependent suite — narrower than the full run, not weaker for the code
# being committed. CI still runs the whole suite with coverage.
if [ "$WP_FULL" = "1" ]; then
pnpm test || exit 1
else
changed_src=$(echo "$staged" | grep -E '^src/.*\.(ts|vue)$' | tr '\n' ' ')
if [ -n "$changed_src" ]; then
# shellcheck disable=SC2086
pnpm exec vitest related --run $changed_src || exit 1
fi
fi
# Bundle budget — cheap, and a regression here stays invisible until release.
pnpm build:extension || exit 1
pnpm size || exit 1
fi
if [ "$PY" = "1" ]; then
if command -v ruff >/dev/null 2>&1; then
ruff check . || exit 1
fi
if command -v pytest >/dev/null 2>&1; then
pytest -q || exit 1
fi
fi
exit 0
+43 -15
View File
@@ -18,6 +18,21 @@
* notes shrink to just the meaningful changes (typically <50 entries
* per release).
*/
import fs from "node:fs";
// Curated, human-readable highlights for the NEXT release. A maintainer
// writes plain-language "what changed for you" bullets here before merging a
// release; if the file is empty/absent the release body simply falls back to
// the auto-generated grouped changelog. The file is read at release time (in
// CI) and cleared by the prepare step below so highlights never repeat.
const HIGHLIGHTS_FILE = "docs/release-notes/next.md";
let highlights = "";
try {
highlights = fs.readFileSync(HIGHLIGHTS_FILE, "utf8").trim();
} catch {
/* no curated highlights for this release */
}
export default {
branches: ["main"],
plugins: [
@@ -69,24 +84,30 @@ export default {
],
},
writerOpts: {
// headerPartial puts a fixed "TL;DR + useful links" block at
// the very top of every release body, so the first ~600
// chars GitHub renders above the "Read more" fold on the
// releases list view stay useful no matter how long the
// changelog gets. {{version}} expands to the new tag.
// The body IS the curated notes. See docs/release-notes/next.md for
// the structure and the rules that keep it short.
//
// The per-commit list used to be inlined here inside a <details>.
// It was dropped: with the hidden-type filter already applied it
// said the same things as the curated notes, only worse, and it grew
// linearly with the release (81 entries for this one). A compare
// link is strictly more useful -- diffable, searchable, one line.
headerPartial:
"## 🎉 Wildcard Pipeline {{version}}\n\n" +
"📖 [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)\n\n" +
"<details>\n" +
"<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>\n\n",
// footerPartial closes the <details> wrapper so the wall of
// commits stays collapsed by default. GitHub renders
// <details> natively in markdown — readers click to expand
// when they actually need the per-commit log.
footerPartial: "\n</details>",
(highlights ? `${highlights}\n\n` : ""),
// Compare link instead of the commit dump.
footerPartial:
"\n---\n\n**Full changelog:** " +
"[{{previousTag}}…{{currentTag}}]" +
"(https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/{{previousTag}}...{{currentTag}})\n",
// Render no commit sections. The curated notes above ARE the release
// notes; commits still drive the version bump (see commit-analyzer)
// and still live in git -- they are simply not restated here.
transform: () => false,
},
},
],
@@ -98,18 +119,25 @@ export default {
"@semantic-release/exec",
{
// 1. Stamp version into package.json + pyproject.toml.
// 2. Pack the deployable file set into a zip so the
// 2. Rebuild js/ + web/ so the bundle's baked-in version matches the
// stamped pyproject.toml. The server reports that version and the
// canvas + manager ask for a reload while the two differ, so a zip
// packed from the pre-stamp build would nag on every page load.
// Pack the deployable file set into a zip so the
// @semantic-release/github plugin can attach it to the
// release. Zip is built AFTER versioning so the file lands
// with the right version in its name.
// 3. Clear the consumed highlights file so the next release starts
// from a clean slate (the content already lives in this release's
// notes + CHANGELOG.md).
prepareCmd:
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node scripts/pack-release.mjs ${nextRelease.version}",
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node -e \"require('fs').writeFileSync('docs/release-notes/next.md','')\" && pnpm build && node scripts/pack-release.mjs ${nextRelease.version}",
},
],
[
"@semantic-release/git",
{
assets: ["CHANGELOG.md", "package.json", "pyproject.toml"],
assets: ["CHANGELOG.md", "package.json", "pyproject.toml", "docs/release-notes/next.md"],
// Plain message — `${nextRelease.notes}` here would expand to
// the entire changelog, which on a divergent history (where
// semantic-release can't find the last tag in main's first-
+901
View File
@@ -1,3 +1,904 @@
## 🎉 Wildcard Pipeline 2.20.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)
### Features
- **Pick which images go on to the upscaler with WP Image Filter.** Put the new node between your first KSampler and the upscaler. When a run reaches it, a picker opens with every image (grouped by frame when a Context Loop made them). Click the ones you want, click the frame around an image (or **Zoom & refine**, or **Space**) to look closer, and **Enter** to send them on. Only the picked images continue, and their latent, masks, positive and negative conditioning, prompt texts and Context come along with them, so the upscaler gets each image's own prompt. Two **extra** slots carry anything else that should follow the picks. **Keep all** lets everything through and **Stop branch** stops what comes after the node without failing the run. On the node you choose whether it pauses every run, reuses your last picks, or lets everything pass, a timeout, and whether picks keep their batches or go on one image per item. The picker can be tucked away to look at the canvas and keeps your picks until you open it again. While it waits, a short chime plays and the tab title starts with **● Pick images**, so you notice from another tab (new settings: **Image Filter sound**, on by default, and **Image Filter desktop notification**, off by default). No schema change.
- **Refine your picks before they reach the upscaler.** In the Image Filter zoom, a **Refine** panel lets you edit the frame's positive and negative prompt and paint a mask on the image. An edited prompt replaces the text output, and with a CLIP wired into the node's new optional **clip** input the conditioning is re-encoded to match. A painted mask goes to the **masks** output, so an inpainting upscaler can work on just that area.
- **The Image Filter picker knows your Context Loop.** Frames are labelled with the options your sweep pinned (hover for the seed and prompt), a sweep opens as a grid where you choose which wildcards go on rows and columns and the rest split into small grids (click a row, column or group name to pick all of it), **C** compares two images with a slider, **Fit** (**F**) makes a few images fill the picker, **I** adds a Details panel to the zoom (the frame's values, its other images, and each value marked in the prompt), and the picker opens zoomed if you left it zoomed. **Send to Loop** writes your picks back to the loop, locking the kept frames' seeds and bypassing the rest, so the next run repeats only what you liked.
### Fixes
- **Sweep combinations can clear a wildcard's options in one click.** Next to **all** and **enabled only** there is now **none**, so you can untick everything and pick just the few options you want instead of unticking them one by one. A wildcard with nothing ticked sweeps nothing until you tick one.
- **Editing a loop frame goes back to base when that frame goes away.** With frame #5 picked under **edit frame**, lowering the count below 5 or changing the sweep left you editing a frame that no longer existed (or now meant a different combination). It now switches back to **base**.
- **Frame locks and bypasses no longer come back on the wrong frame.** Lowering a Context Loop's count used to keep the seed locks and bypasses of the frames past it, and raising the count again brought them back. Now they are dropped. Changing a sweep moves each lock and bypass with its combination, or drops it when that combination no longer runs.
- **The loop's count is locked while a sweep is on.** The sweep decides how many frames run, so the count field now greys out and shows the sweep's frame count instead of looking editable.
---
**Full changelog:** [v2.19.0…v2.20.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.19.0...v2.20.0)
## 🎉 Wildcard Pipeline 2.19.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)
### Features
- **Make your pipeline react to the loaded checkpoint with WP Model Info.** Put the new node first in the chain and wire your checkpoint's MODEL into it. It writes `$model_family` (sdxl, flux, sd15, …, read from the model), `$model_variant` (pony, illustrious, noobai or animagine, read from the file name by rules you can edit on the node) and `$model_name` (each can be pinned to a value of your own, and they stay out of the Assembler unless you switch them on), so derivations can branch on the model you loaded. The derivation editor's new **LoRA by model** button adds a ready-made rule that sets `$loras` per variant. To drive a constraint from the model, set the new **Match variable** option on a wildcard (in its node settings, under Runtime): it picks the option whose text equals a variable instead of rolling, so a "Model" wildcard with pony and illustrious options becomes a normal constraint source. No schema change, so shared modules work on older versions too.
- **Bring your A1111 and Forge wildcards along with the new Wildcard packs tab.** On **Import / Export → Wildcard packs**, choose a folder, files or a `.zip` (or drop them; a pack dropped on the Import tab lands here too). Dynamic Prompts, PPP and Impact Pack `.txt`, `.yaml` and `.json` wildcards are named by path the way `__animals/cats__` finds it, and a folder of related lists merges into one wildcard tagged by list, like one you'd build by hand: `hair/colors/red.txt`, `blonde.txt` and `dark.txt` become one `hair/colors` wildcard with the tags red, blonde and dark, and each folder level becomes a tag group. Billions of Wildcards goes from 2,653 lists to 935 wildcards this way. A reference to a merged list becomes a tag filter on the merged wildcard, so it still draws from that list, and any merged row can be split back into separate lists with one click. Before anything is imported you review the plan: every wildcard is sorted by role (entry point, composition, vocabulary, group) and marked Exact, Close or Needs a look, by domain, with the reason one click away. `__references__` (globs included) turn into `@{}` links, `__2$$name__` picks two different values like it did before, a line written twice keeps its double weight, and weights, PPP labels and `else` choices carry over as weights, sub-categories and the fallback. Only the entry points go into bundles (one per domain inside a pack bundle), so dropping a bundle rolls the prompts the pack was written for while the lists stay in the library. Each wildcard gets a category per domain and a library tag for the whole pack, `#` comments become its description, and importing the same pack again finds what you already have instead of adding copies.
- **Compare options side by side with Sweep combinations on the Context Loop.** Open **Sweep combinations** on the loop node, pick wildcards from the Contexts after it and tick the options you want, and one Generate runs every combination, one frame each: three hair colours against three moods makes nine prompts in grid order. The menu shows the total and lists every frame before you run, and **Hold other picks** keeps everything you didn't sweep the same across frames, so only what you swept changes. A limit (64 by default) caps big sweeps.
- **Give a wildcard a fallback for when constraints rule everything out.** Hover an option's probability in the wildcard editor and click **set as fallback**. That option still rolls normally with its weight, and it's also used whenever nothing else is left to pick, so a hair wildcard whose constraints exclude every colour can say "loose hair" instead of nothing, without a duplicate option. It also kicks in when every weight is 0. Give it weight 0 if it should only ever stand in. One fallback per wildcard, it works for nested `@{}` picks too, and WP Debug and the Test Runner note "Fallback used" where it took over. Untick it on a node to keep the empty result there. Shared wildcards with a fallback are stamped schema 9, so older versions refuse them and ask for an update.
- **Choose whether the Community tab shares anonymous usage stats.** The Community tab now tells the community site which pages are opened and when packs are installed, published or signed in to, so we can see what's used. It sets no cookies and sends no account details. Turn it off under **Settings → General → Privacy**. It's also off when your browser sends Do Not Track.
- **Tidy prompt syntax with three new Prompt Cleaner rules.** **Empty groups** removes leftovers like `()`, `[]` and `(:1.2)`, which you get when a weighted `@{}` ref resolves to nothing. **Merge weights** folds stacked weights into one, the way ComfyUI multiplies them: `((a:1.1):1.2)` becomes `(a:1.32)`, and `(a:1.0)` becomes plain `a`. **LoRA spacing** turns `< lora : x : 0.8 >` into `<lora:x:0.8>` and puts a space between a LoRA tag and a word it touches. The Balanced preset (the default) now turns on empty groups and LoRA spacing, so existing Cleaners on Balanced tidy these on the next run. Merge weights is on in Aggressive. Each rule has its own row, so you can turn it off per node.
- **See which constraints can leave a wildcard empty before you run.** When some value of a constraint's source rules out every option of its target (all excluded, shut out by an Only rule, or weighted 0) and the target has no fallback, the constraint row on the canvas now shows a **rules out all** badge. Hover it to see which source values do it, then allow an option for them or give the target a fallback.
### Fixes
- **Update Now works with the Manager built into ComfyUI.** It only knew the older ComfyUI-Manager custom node, so with the built-in Manager it fell back to "update manually", and the Restart button in Settings was missing. After **Restart ComfyUI** the page now waits for ComfyUI to come back and reloads itself, so it never keeps showing the old version. If ComfyUI comes back still on the old version, the dialog says the update didn't apply instead of claiming success.
- **Bypassed and muted Assemblers and Prompt Cleaners now dim their text boxes too.** The prompt and negative boxes used to stay bright while the rest of the node dimmed.
- **The Sweep Limit field no longer hides its number under the arrows.** It now has the same small up/down arrows as the seed fields.
- **Importing modules together with their categories now works.** A JSON import that brought in a new category along with the modules filed under it could fail with "database integrity violation". Modules whose category merges into one you already have by name now land in that category.
- **Import / Export sees your whole library.** With more than 1000 modules, the Export list and the Import duplicate check only looked at the first 1000, so re-importing a big pack showed everything past that as changed.
- **The All items list shows your whole library.** It stopped at the first 1000 modules and 1000 bundles, so after importing a big wildcard pack the rest never showed up there.
---
**Full changelog:** [v2.18.0…v2.19.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.18.0...v2.19.0)
## 🎉 Wildcard Pipeline 2.18.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)
### Features
- **Send words to the negative prompt from the pick that needs them.** Wildcard options and fixed values get an optional Negative line under their value, combines get one Negative for the whole phrase, and derivations get a fourth action, **Add to negative**. Pick "strawberry blonde" hair and "strawberry, fruit" lands in the negative. The WP Prompt Assembler has a new `negative` output and a negative template where `$negatives` marks the spot. Negatives follow usage: an Assembler only collects the words of variables its template rendered, so a face detailer never gets the hair negatives, and repeated tags are dropped. Negatives never change your picks. Context Injector rows can carry a negative, the Prompt Cleaner cleans a negative next to the prompt with its own rule column, bulk add takes `value #tag *N -- negative` (or `name = value -- negative` for fixed values), and WP Debug and the Test Runner show where each word came from. Shared modules that use a negative are stamped schema 8, so older versions refuse them and ask for an update. The docs have a new Negatives page.
- **A derivation branch can do several things.** Click **+ And** under a branch's action to add another one, so a single rule can read "if tier = warmup then $pose replace $pose_portrait AND add `extra arms` to $pose's negative". The actions run in order, and each one sees what the one before it wrote. On the canvas, a branch with several actions shows them all in its summary and gets one value override per action, and WP Debug lists every action a branch ran. Shared derivations that use this are stamped schema 8.
- **Reach a variable's negatives with `$name.neg`.** `$pose.neg` is the negative words `$pose` carries right now, and `$props.1.neg` is only pick 1's. Use it anywhere `$vars` work: in an "Add to negative" action to hand one variable's negatives to another, in a combine, in the Assembler's negative template, or in a condition (`$hair.neg contains blonde`). Reading `.neg` never counts as using the variable, so its words aren't collected twice. Autocomplete offers `.neg` after the dot, and it shows in red. Because of this, an accepts tag group can't be called `neg`.
- **Replace a variable's negative, not just add to it.** Derivations have a fifth action, **Replace negative**. It drops the negative words a variable carries and uses the action's text instead, still without touching the variable's own text. Shared derivations that use it are stamped schema 8.
- **A roomier Rules section on the canvas.** A derivation's node modal now shows each IF, ELIF and ELSE as its own card. Every test and every action gets its own row with a full-width field, so long values wrap instead of being squeezed into a table column. AND / OR groups read as indented rows under an "ANY of" / "ALL of" header. An empty field shows the library value (chips included) until you type, and an overridden field lights up with the library value underneath and a **Use library** link to undo it.
- **Derivation branches can combine tests with AND / OR.** Click **+ Condition** under a test to add another, and click a connector to switch it between AND (all must match) and OR (any may match). Each connector flips on its own, with AND binding tighter than OR, so "a AND b OR c" reads as "(a AND b) OR c" and the AND part is shown boxed. **+ Group** nests a boxed list with the opposite connector, so a branch can read "night AND (rain OR fog)". On the canvas, a grouped branch shows the whole expression and gets one value override per test. Shared derivations that use this are stamped schema 7, so older versions refuse them and ask for an update instead of failing mid-run.
- **Placeholders for modules you haven't built yet.** Type `@` and a name nothing in the library has, and the last row of the list offers **Placeholder @name**. It inserts a red chip under that name, which you later click to point at the real module. No more hand-typing `@{…#name}`.
- **Broken references are marked where they are.** A field holding a red chip gets a red outline, and its row gets marked too: a wildcard option (in the library editor and on the canvas) or a derivation branch and its rule. Before, only the list view's warning triangle said something was wrong.
- **Test Runner baselines: see what an edit changed.** Run a saved scenario, open the new **Compare** tab and choose **Save as baseline**. After you edit a module, run the scenario again and Compare lists every seed whose output changed, with the removed and added words highlighted, plus any variable values and warnings that appeared or went away. Seeds are deterministic, so for an unchanged stack every seed matches, and a real change never gets lost among random ones. A scenario on random seeds can rerun the baseline's own seeds in one click.
- **Re-run all pinned scenarios at once.** **Re-run all** next to the Pinned heading in the Test Runner runs every pinned scenario as saved. Each one's badge then says whether it still **matches** its baseline or how many outputs **changed**.
- **See which scenarios use a module.** The wildcard and derivation editors show **In N scenarios** beside the Test Runner button. It opens the Test Runner with the list narrowed to those scenarios.
- **Inspect any module in a Test Runner scenario.** Click a stack card to see how that module is set up next to what the last run did with it. A wildcard shows each option's share of the weight beside how often it was actually picked, and flags options that never came up. A constraint shows what it links, how far it reaches and how many times it applied. Combines, derivations, fixed values and bundles each get their own view, and **Edit** opens the module's editor.
- **Test Runner guide in the docs.** Documentation now has a Test Runner page covering scenarios, reading a run, the inspector and baselines.
- **Tags work as collections.** A module, bundle or template can carry several tags, and now they have a home. The new **Tags** page (under Library in the sidebar) lists every tag with how many items carry it, opens a tag as a filtered list, and renames, merges or deletes one across the whole library in one step. With two or more tags in a list filter, switch between **Any tag** and **All tags**.
- **WP Debug explains the run.** The Debug node now has four tabs. **Variables** lists every value with the step that set it. **Trace** groups the steps by Context node, and each step opens to say why it did what it did: which derivation branch fired and the actual value behind every condition, a wildcard's odds and the constraints that re-weighted it, how far a constraint reached, and where a nested `@` pick came from. **Warnings** gives every warning a plain label and links it to its step, and **Raw** keeps the JSON. Derivation rules fold one at a time, and a step can jump to its node on the canvas.
### Fixes
- **Turning off one fixed value no longer turns off the rest.** Fixed values whose rows had no ids (common in imported packs) switched on and off together on the canvas. Each row now toggles on its own, both in the editor and when the workflow runs.
- **Read-only previews keep a variable's accessor.** Rule summaries on the canvas, the debug view and other previews showed `$outfit.SHOES` as plain `$outfit`. They now show the whole reference.
- **Wildcard icons stay purple on the canvas.** Opening a modal or adding certain nodes could repaint every wildcard's icon grey until the page was reloaded.
- **WP Debug shows a cut-off value in full when you open its step.** A trace row fits its value on one line, and opening the step only repeated it in full when it was over 60 characters, so a shorter value cut off by a narrow node stayed cut off. Opening the step now shows any value the row had to cut.
- **WP Debug's "show node" works again.** It did nothing in workflows whose node ids are text (like `wpctx`) rather than numbers, and for nodes in a subgraph (or back at the root while you were inside one). It now finds the node, opens its graph if needed, then selects and centres it. Trace headers now name a Context node by its codename (the `navy-dove` shown on the node), with its title and graph id in the tooltip.
- **The Context node warns about broken refs in a derivation's canvas overrides.** A ref typed into a value override on the canvas was never checked, so pointing it at a deleted module (or leaving a placeholder there) showed no warning on the node.
- **Reopening a derivation no longer changes its presence checks.** The editor rewrote every "exists", "does not exist", "is set" and "is unset" condition to "equals" when you opened a saved derivation, so saving it again changed what it did.
- **"Exists → is empty" conditions run.** The editor offered it, but the engine rejected the op and the whole derivation failed. The engine now supports `is_empty` and `is_not_empty`.
- **Test Runner in the light theme.** The closed trace drawer no longer casts a dark shadow down the right edge of the page, and the drawer and module picker shadows now follow the theme.
- **Long Context chains no longer slow the canvas down.** Every Context node used to re-work its whole upstream chain several times a second, so the cost grew with the square of the chain's length. Nodes now reuse the last result until something upstream actually changes. On a 16-node chain, a round of refreshes went from about 28 ms to under 1 ms.
- **A reference that resolves to nothing no longer leaves a gap.** A placeholder or deleted module, a filter that matches no option, or an empty option used to leave a double space or a stray comma behind, so `red, @missing, dress` came out as `red, , dress`. It now comes out as `red, dress`, in the run and in the editor previews.
---
**Full changelog:** [v2.17.2…v2.18.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.17.2...v2.18.0)
## 🎉 Wildcard Pipeline 2.17.2
📖 [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)
### Fixes
- **Faster startup.** Opening ComfyUI used to download 43 of the extension's files before the canvas finished loading, even with no Wildcard Pipeline node on it. It now downloads 2 (166 KB → 92 KB gzipped). The toast stack, the Display playground, the template editor and the subgraph conflict badge now load the first time you use them.
---
**Full changelog:** [v2.17.1…v2.17.2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.17.1...v2.17.2)
## 🎉 Wildcard Pipeline 2.17.1
📖 [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)
### Fixes
- **What's new shows the release you are running.** After an update, the page could keep showing the notes of an older release for a few hours ("Release notes for v2.15.3 — you are running v2.17.0"), because it trusted a release check cached before the update. It now headlines the newest release it knows about, and a cached check older than your version is fetched again.
---
**Full changelog:** [v2.17.0…v2.17.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.17.0...v2.17.1)
## 🎉 Wildcard Pipeline 2.17.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)
---
**Full changelog:** [v2.16.0…v2.17.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.16.0...v2.17.0)
## 🎉 Wildcard Pipeline 2.16.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)
### Features
- **Link one wildcard's pick to another's with the new Only rule.** A constraint rule set to **Only** turns its row into a short list. An exception *maid → frilled apron* in Only mode means that whenever the source rolls "maid", the target can only roll "frilled apron", and an outfit you add to the target later is shut out too. Before, the same link needed an Exclude rule for every other outfit, and any outfit added afterwards slipped through. In the matrix, an Only cell links subcategories the same way, and the other cells in that row show a faint × so you can see what is shut out. Only is in the cell popover, the exceptions mode picker and the canvas instance editor. Constraints that use it publish as schema version 6, so an older extension refuses them and asks you to update instead of failing the constraint.
### Fixes
- **Setting a matrix cell back to Neutral on the canvas no longer breaks the constraint.** Overriding a library rule to Neutral in a Context node's constraint editor saved a value the engine didn't accept, so the whole constraint stopped applying when the graph ran. The editor now saves it correctly, and workflows saved with the old value run again.
- **The constraint docs described Allow wrongly.** They said Allow keeps only the matching options. Allow has always meant "no change" (it shows as Neutral in the matrix). The docs now say so, and the keep-only behaviour they described is what Only does.
---
**Full changelog:** [v2.15.3…v2.16.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.15.3...v2.16.0)
## 🎉 Wildcard Pipeline 2.15.3
📖 [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)
New docs for chips, references and the filter grammar.
### Highlights
- **Docs: Chips & references.** A new page in the manager's Docs tab (How it connects → Chips & references) explains the chips you see in text fields and their three states (resolved, filtered, broken), how to make a placeholder `$var` chip before anything produces it, the full `@{uuid#name:filter!null}` reference form and why plain `@name` stays text, and the sub-category filter grammar: `not`, `and`, `or` (or a comma) and parentheses, e.g. `not warm and (low or high)`. Searching the docs for "chip" or "filter" now finds it.
---
**Full changelog:** [v2.15.2…v2.15.3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.15.2...v2.15.3)
## 🎉 Wildcard Pipeline 2.15.2
📖 [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)
### Fixes
- **`$outfit.1.SHOES` now reads the second pick's shoe.** On a wildcard that picks several options, an indexed axis read skipped any pick whose option carries no tag on that axis, so every later index slid down by one: with `robe, jeans` picked, `$outfit.0.SHOES` printed the jeans' boots beside the robe and `$outfit.1.SHOES` came out empty. Each index now names the same pick as `$outfit.1`, in templates and in derivation conditions alike. The bare `$outfit.SHOES` read was already right and is unchanged.
- **The Prompt Assembler preview shows the tag that was actually rolled.** The preview filled `$outfit.SHOES` with the first tag of the group whatever the option, so it could read `sandals | robe | red stilettos` for an outfit with no shoes and a pair of heels. It now reads the same roll as the values beside it, and matches what the prompt will say.
---
**Full changelog:** [v2.15.1…v2.15.2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.15.1...v2.15.2)
## 🎉 Wildcard Pipeline 2.15.1
📖 [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)
### Fixes
- **Library file exports now record the schema version their content needs.** Exporting from the library always wrote schema version 2 into the file, even when it held range multi-picks like `{1-3$$…}`, constraints with a narrowed reach, or wildcards with an **accepts** tag axis. An older extension would then import the file and silently misread or drop those features. Exports now say 3, 4 or 5 when they use those features, so an older extension refuses the file and asks you to update. Files without them still export as version 2.
---
**Full changelog:** [v2.15.0…v2.15.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.15.0...v2.15.1)
## 🎉 Wildcard Pipeline 2.15.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)
### Fixes
- **Shared wildcards with accepts tag axes now say when your extension is too old for them.** A wildcard whose tag group is marked **accepts** is now published as schema version 5. Before, it went out as version 2, and an extension from before 2.14 installed it without complaint while quietly dropping the accepts setting, so `$outfit.SHOES` read empty and constraints lost the rolled tag. That older extension now refuses the pack and asks you to update. Wildcards without an accepts group publish exactly as before.
- **Updating a community item no longer re-runs the old tag conversion on it.** "Update" and "Install as new entry" labelled every download as the oldest schema version, so each install ran the version 1 to 2 upgrade again over content that was already current. Downloads now keep the version the community recorded for them, and so do dependencies fetched from a constraint's "download missing" action.
---
**Full changelog:** [v2.14.1…v2.15.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.14.1...v2.15.0)
## 🎉 Wildcard Pipeline 2.14.1
📖 [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)
### Fixes
- **Cutting an already-empty field no longer wipes your clipboard.** Cut an option's text, press Ctrl+A Ctrl+X again on the now-empty field, and the text you meant to paste was gone: the editor let the browser cut an invisible caret marker, which replaced what was on the clipboard. The second cut now leaves the clipboard alone, and a paste never writes that invisible character into a value.
---
**Full changelog:** [v2.14.0…v2.14.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.14.0...v2.14.1)
## 🎉 Wildcard Pipeline 2.14.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)
Read a wildcard's chosen tag as a variable, a much faster ComfyUI start, and a bulk-delete fix worth reading.
### Highlights
- **Tag groups can now be axes you read as variables.** Mark a wildcard's tag group **accepts** and it becomes an *axis*: the wildcard rolls exactly one of its tags each pick, and you can read that tag anywhere with **`$var.AXIS`**. An `$outfit` wildcard with an accepts group `SHOES` exposes **`$outfit.SHOES`** as the shoe it chose this run — the same value in every template, combine, derivation and chained node, because it is rolled once when the option is picked rather than each time it is read. Groups left **classify** (the default) behave exactly as before, so nothing already in your library changes.
Where it pays off is constraints. When an accepts axis is the **source** of a constraint, the rule keys on the single rolled tag rather than every tag the option happens to carry — so a diagonal "same tag wins" matrix pins the target to exactly what was rolled, and `$outfit.SHOES` and the shoe that gets picked agree every run. If the **target** is also an accepts axis, it stays in the pool when the source allows any of its alternatives and then rolls the matched one, so the two wildcards never disagree on the axis. The editor marks accepts groups in the constraint matrix, warns when an option carries none of its axis's tags, and offers a wildcard's axes in the `$` autocomplete.
- **Optional booru tag autocomplete.** Turn it on in Settings → Tag autocomplete and typing a few letters into an option value suggests danbooru tags, ranked by how often they are actually used, colour-coded by kind (character, copyright, artist, meta) and resolving aliases — type `hires` and it offers `highres`, naming what it matched so the jump is never mysterious.
It stays out of the way of everything you already use: `$` variables and `@` references always win the caret, and suggestions never appear while you are typing one. Off unless you switch it on.
The tag list is a file you install once — press **Download** in Settings to fetch it from this project's GitHub release, or drop your own CSV at the path shown there. **This is the only thing this extension ever fetches from the internet**, it only happens when you press the button, and the [README](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline#network-access) documents exactly what that request does and the restrictions around it.
- **This extension was making ComfyUI slow to load, and it will clean up after itself on the next start.** Our frontend files carry a content hash in their names, so every release produced new filenames — and ComfyUI Manager installs an update by writing the new files over the old ones without removing what the new version no longer ships. Nothing ever deleted the previous version's files, so they piled up with every update.
On a real install that had been updated a dozen times: **1,774 files taking 60 MB, where about 300 were live.** ComfyUI requests every JavaScript file an extension exposes when the page loads, so that install was fetching 237 of our chunks instead of 58 — nearly 10 MB, which was 42% of all extension code on that page and by a wide margin the largest single contributor to its load time.
This release records what each build produces and deletes anything left over from an older version when ComfyUI starts. **No action needed — the first start after updating does the cleanup**, and it is conservative: only files this project's build system generates are ever removed. On the install measured above it freed 52 MB and removed 1,579 files.
- **Sourcemaps are no longer shipped.** They were being written on every build and included in the package, but never linked, so no browser had ever requested one — 22.5 MB of files that existed only to be ignored. Debug builds can still produce them.
- **"Select all" now means the rows you can see.** With a filter active, the header checkbox selected *every* row, not the filtered ones — so narrowing a list down to a handful, selecting all and deleting removed the whole list, most of it never on screen and with no warning. This affected the **wildcard** and **fixed-values** editors, and on wildcards it reached bulk weight and tag changes too: setting a weight on a filtered selection silently rewrote every option's weight, with no change in row count to hint at it. Select-all is now scoped to what the filter is showing, in both directions. If you have ever lost options or had a weight distribution flattened, this was why.
- **A constraint can no longer hold two rules for the same pair.** Adding the same source/target combination twice — say `rain → sandals` as both *exclude* and *boost ×3* — looked like two active rules, but only one ever applied: whichever came last in the list. Order decided the outcome, nothing showed you the order, and in the worst case a deliberate **exclude was silently thrown away** by a later duplicate. Saving or importing a constraint with a repeated pair is now refused, naming the pair so you know which one to remove. Existing constraints keep working exactly as before — nothing already in your library is rejected, and no generation changes.
- **Constraint exceptions and derivation rules have a filter bar.** The wildcard and fixed-values editors got one previously; these two did not, which was backwards — an exception list is routinely longer than the wildcard it filters. Type to narrow the list, with a count and a clear button.
Rules keep their real numbers while filtered, so "Rule 5" stays Rule 5 instead of renumbering to 1, and editing or deleting a filtered row acts on that row rather than the one that happens to share its position.
- **The library-change check stopped re-downloading your whole library every five seconds.** Both the manager and the in-graph Context node watch for library edits made elsewhere, by polling a list of module hashes. That list was re-sent in full on every tick with no caching headers — 33 KB every five seconds on a real library, roughly **24 MB an hour per open tab**, sitting idle. Producing it also re-read and re-parsed every module payload in the database each time.
The check now answers "nothing changed" with an empty response the browser already knows how to handle, and works that out from a cheap row count rather than by rebuilding the list. On a local install this was invisible; if you reach ComfyUI over Tailscale, a VPN or any metered connection, it was the largest thing this extension did.
- **The webfonts are no longer bundled into JavaScript.** Both fonts were being base64-encoded into a code file that ComfyUI had to download and parse *before* it could finish registering this extension's nodes — about 90 KB of JavaScript that was never really JavaScript. They are now ordinary font files the browser fetches on its own, in the background, while everything else carries on. Node registration no longer waits for them.
Two things follow from that. Text appears sooner on a slow CPU, because 90 KB stopped going through the JavaScript parser. And the fonts now stay cached when you update — previously every release changed the code file they were hidden inside, so your browser re-downloaded them every time.
Total download on a fresh install is roughly unchanged: the encoding overhead and the compression that undid it cancel out. The gain is in *when* the work happens, not how much of it there is.
<!-- /modal -->
### Upgrade notes
Nothing to do beyond updating. If the interface renders briefly in a system font on the first load after this update, that is the fonts arriving in the background as intended — it settles within a moment and does not recur.
---
**Full changelog:** [v2.13.2…v2.14.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.2...v2.14.0)
## 🎉 Wildcard Pipeline 2.13.2
📖 [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)
### Firefox: the editor no longer dies when you insert a nested reference
Picking a wildcard from the `@` autocomplete could take down the whole editor —
the field stopped accepting text, Backspace did nothing, and Save and Cancel
stopped responding until the page was reloaded. Select-all then cut inside an
option value did the same thing.
Both were the same cause. Firefox removes the empty text nodes that Vue uses to
mark where each chip and text run begins and ends, and once they are gone any
edit that adds or removes a chip runs off the end of the field's contents.
Chromium keeps those markers, which is why this only ever happened on Firefox
and never reproduced during development.
The editor now notices when those markers have been removed and rebuilds the
field instead of tripping over them. This affects every rich text field —
option values, combine templates, derivation rules and injector bindings, on
the canvas and in the manager alike.
### Your library is protected against version mismatches
If a database has been through migrations that this build does not recognise —
after downgrading, hand-installing an older copy, or switching versions in
ComfyUI Manager — Wildcard Pipeline now stops with a clear message instead of
quietly reading a library it does not understand. Previously it carried on, and
every later edit was written through assumptions that no longer held.
### Release archives now include the interface translations
The release zip was missing `locales/`, so node names, descriptions and setting
labels fell back to raw identifiers for anyone installing from it. Installs
from ComfyUI Manager and the Comfy Registry were unaffected.
---
**Full changelog:** [v2.13.1…v2.13.2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.1...v2.13.2)
## 🎉 Wildcard Pipeline 2.13.1
📖 [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)
A security fix for the database location setting. Update when you can.
> **Already on 2.13.0?** This is the same release. 2.13.0 reached GitHub but not the
> ComfyUI Registry, so it never arrived for anyone installing through ComfyUI Manager.
> 2.13.1 is that same build under a version number the registry will accept — nothing
> in it differs.
### Highlights
- **The "move the database" setting could be abused to touch other files.** Changing the database location schedules a file copy or move that runs the next time ComfyUI starts. The two paths were only checked for being absolute, not for being database locations — so a request sent to your ComfyUI port could have scheduled a copy or move between any two files on the machine, including reading a file by copying it somewhere this plugin serves over HTTP. ComfyUI's API has no authentication, so anything that can reach the port could do this. Both paths are now restricted to the three locations the database is actually allowed to live in. Moving your database between those locations works exactly as before.
- **What's new now shows the releases you skipped.** The page only ever showed the newest release, so updating across two versions meant the middle one's notes were simply gone — 2.12.0 was current for about an hour before this release replaced it. The last few releases are now listed underneath, collapsed, each with its own notes. **All releases** also goes to the releases index now instead of to the single release it was labelled against.
<!-- /modal -->
### Upgrade notes
Nothing to do beyond updating. No settings change, and any database move you had queued still runs normally.
If you expose ComfyUI beyond your own machine, this one is worth taking promptly — the affected setting is reachable without a login on every install of 2.11.0 and 2.12.0.
<!--
HOW TO WRITE THIS FILE
Order, and nothing else:
Headline one sentence, what this release is about
### Highlights 4-5 max
<!- /modal -> the update dialog stops rendering here
<details> Also in this… grouped one-liners, collapsed
### Upgrade notes only when the reader must act; delete otherwise
— keep this OUTSIDE the <details>, it needs seeing
This file IS the release body — the compare link is appended automatically and
the per-commit list is deliberately not included. It is consumed at release
time and cleared afterwards, so anything left here ships.
RULES
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
6-commit release and an 80-commit release roughly the same height. If a
sixth feels essential, something else was not a highlight.
2. A highlight passes one of three tests:
- new capability — you can do something you could not before
- wall removed — something that blocked you now works
- surprising — behaviour changed in a way you should not meet alone
Polish, truncation, layout and invisible perf fail all three.
3. Commit count is NOT a test. The categories icon picker was two commits and a
real feature; `ui` was nine commits and almost entirely polish.
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
names are free-form — use whatever the release actually contains. It sits in
a <details> so a long release does not double the page; put the count in the
summary so the reader knows what they are opening. That number is written by
hand here and can drift — the update dialog computes its own from the same
bullets, so if the two disagree, this one is wrong. Note also that collapsed
text is not reliably reachable by browser find-in-page, which is the cost.
5. Write what changed for the user, not what changed in the code. Name the
symptom, not the fix: "the field went blank" beats "guarded a null deref".
6. Perf belongs in the tail WITH its numbers, so the people it affects
recognise themselves. "Performance improvements" tells nobody anything.
7. Security fixes say what an attacker could have done, in plain language, and
whether the reader must act. Never ship one as "hardening" or "internal
improvements" — someone deciding whether to update tonight needs the facts,
and vagueness reads as a cover-up once the diff is public anyway.
-->
---
**Full changelog:** [v2.13.0…v2.13.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.0...v2.13.1)
## 🎉 Wildcard Pipeline 2.13.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)
A security fix for the database location setting. Update when you can.
### Highlights
- **The "move the database" setting could be abused to touch other files.** Changing the database location schedules a file copy or move that runs the next time ComfyUI starts. The two paths were only checked for being absolute, not for being database locations — so a request sent to your ComfyUI port could have scheduled a copy or move between any two files on the machine, including reading a file by copying it somewhere this plugin serves over HTTP. ComfyUI's API has no authentication, so anything that can reach the port could do this. Both paths are now restricted to the three locations the database is actually allowed to live in. Moving your database between those locations works exactly as before.
- **What's new now shows the releases you skipped.** The page only ever showed the newest release, so updating across two versions meant the middle one's notes were simply gone — 2.12.0 was current for about an hour before this release replaced it. The last few releases are now listed underneath, collapsed, each with its own notes. **All releases** also goes to the releases index now instead of to the single release it was labelled against.
<!-- /modal -->
### Upgrade notes
Nothing to do beyond updating. No settings change, and any database move you had queued still runs normally.
If you expose ComfyUI beyond your own machine, this one is worth taking promptly — the affected setting is reachable without a login on every install of 2.11.0 and 2.12.0.
<!--
HOW TO WRITE THIS FILE
Order, and nothing else:
Headline one sentence, what this release is about
### Highlights 4-5 max
<!- /modal -> the update dialog stops rendering here
<details> Also in this… grouped one-liners, collapsed
### Upgrade notes only when the reader must act; delete otherwise
— keep this OUTSIDE the <details>, it needs seeing
This file IS the release body — the compare link is appended automatically and
the per-commit list is deliberately not included. It is consumed at release
time and cleared afterwards, so anything left here ships.
RULES
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
6-commit release and an 80-commit release roughly the same height. If a
sixth feels essential, something else was not a highlight.
2. A highlight passes one of three tests:
- new capability — you can do something you could not before
- wall removed — something that blocked you now works
- surprising — behaviour changed in a way you should not meet alone
Polish, truncation, layout and invisible perf fail all three.
3. Commit count is NOT a test. The categories icon picker was two commits and a
real feature; `ui` was nine commits and almost entirely polish.
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
names are free-form — use whatever the release actually contains. It sits in
a <details> so a long release does not double the page; put the count in the
summary so the reader knows what they are opening. That number is written by
hand here and can drift — the update dialog computes its own from the same
bullets, so if the two disagree, this one is wrong. Note also that collapsed
text is not reliably reachable by browser find-in-page, which is the cost.
5. Write what changed for the user, not what changed in the code. Name the
symptom, not the fix: "the field went blank" beats "guarded a null deref".
6. Perf belongs in the tail WITH its numbers, so the people it affects
recognise themselves. "Performance improvements" tells nobody anything.
7. Security fixes say what an attacker could have done, in plain language, and
whether the reader must act. Never ship one as "hardening" or "internal
improvements" — someone deciding whether to update tonight needs the facts,
and vagueness reads as a cover-up once the diff is public anyway.
-->
---
**Full changelog:** [v2.12.0…v2.13.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.12.0...v2.13.0)
## 🎉 Wildcard Pipeline 2.12.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)
Full support for ComfyUI's Nodes 2.0 renderer, and a pass over everything to do with knowing where a value came from.
### Highlights
- **Every node works under Nodes 2.0.** ComfyUI's new Vue renderer changed how nodes are laid out, sized and scrolled, and our widgets were built against the old one. Nodes now render, resize, scroll and merge their wires correctly under both renderers — including switching between them with a workflow already open.
- **See where a variable or reference comes from.** Hover any `$var` or `@{ref}` chip and the card names the module that writes it, the node it lives in, and — for references — the pool it resolves against and how many options currently match. Variables produced by a Context Injector, by a sibling module, or by another node are all attributed instead of reading "binds at runtime".
- **The `@` and `$` pickers were rebuilt.** Rows carry an identity so two similarly-named entries are tellable apart, pools supplied by a node are marked as such, and the sub-category filter panel is redesigned — chips reflect the parsed expression, and exception values get a picker of real option values instead of a free-text box.
- **Wildcard options and fixed values became editable at scale.** A pinned search bar, tag filtering, drag-to-reorder, and bulk edit that can move rows to top or bottom and add or remove sub-categories across a selection.
- **Text editing in options no longer breaks.** Cut, paste and select-all could leave a field stuck, mangled, or typing into the wrong place, and a selection would skip over chips. Keystrokes also no longer escape a modal into ComfyUI's global shortcuts — pressing a plain letter could trigger a node-definition refresh and freeze the page.
<!-- /modal -->
<details>
<summary><b>Also in this release</b> — 26 smaller changes</summary>
**Sequences and seeds**
- Lock a frame's seed or bypass it straight from its chip on the Context Loop — shift-click to lock, alt-click to bypass — instead of opening the per-iteration seeds dialog
- The Seed List gets the same frame chips; clicking one locks its seed, and frames bypassed by a wired Context Loop are shown but not editable there, since that setting belongs to the loop
- The frame grid collapses, and scrolls once it outgrows four rows, so a hundred-frame loop no longer owns the canvas. Collapsed state is saved with the workflow, and the header keeps reporting how many frames are locked or bypassed
**Library and organisation**
- Categories get an icon picker, and the icon follows the category everywhere it appears
- Export and import pickers gain cross-bucket search and per-row detail (`$variable`, option counts)
- Copy payload emits an importable envelope, stamped with its schema version
- Re-link matches bundles on child content rather than only a stored hash, and offers itself for modules that have no hash at all
- An unlinked bundle now reads as missing rather than modified, and reset-to-snapshot refuses to empty one
**Chips and references**
- Nested `@{uuid}` references are discovered even when written outside a wildcard payload
- Reference hovers name the pool they counted and flag drift against the library
- Wildcard option chips get the same hover card as every other surface
- Sub-category filter state is derived from the parsed expression, so the chips match what the filter actually does
- A reference to a module you have deleted from the library now reads as broken. It used to keep the deleted module's name and colour, and its hover card quoted the option count the module had before it was deleted — the node badge said the reference was broken while the chip inside the editor disagreed
**The manager**
- **What's new** moved to its own page, reached from the topbar with a dot when there is something unread. It is also the only place to read the notes for the version you are running — the update pill only exists while a newer one is available
- The update check no longer burns through GitHub's hourly allowance. It was making one request per page load per consumer, so a few dozen reloads could exhaust the quota for everything else on that connection; results are now reused for six hours and a refusal is honoured rather than retried
**Editing and layout**
- Fixed-value and combine fields grow with their content, with a corner grip that stays reachable and a manual drag that overrides the auto height
- A scrollable field no longer hands its scroll to the page behind it
- Rule previews truncate with an ellipsis instead of being clipped or faded, and long values wrap
- Tag menus stay inside the viewport, keep their own scroll, and fold by axis
- Dropdowns say "type to filter", which they have always supported
- The save shortcut reads `Ctrl` on Windows and Linux instead of a Mac-only glyph
- Test-runner histogram rows are banded so a row reads across its columns, and its module picker survives duplicate names
**Performance and correctness**
- Canvas polling is roughly eight times cheaper on a large graph — the walkers now share one parse instead of re-parsing a 294 KB widget value ten times per node every cycle (~21% of a core down to ~2.6%)
- Wildcard snapshots refresh when the window regains focus instead of waiting out a timer, and expire so the canvas stops drifting from the library
- The Prompt Cleaner no longer rewrites `embedding:name` or `<lora:name:1.0>` references — punctuation stripping renamed embeddings ending in `_`, and fuzzy dedupe dropped one of two versioned files
- Variable override counts report writers rather than write statements, so a module that creates a variable is no longer described as overriding writes that never happened
</details>
<!--
HOW TO WRITE THIS FILE
Order, and nothing else:
Headline one sentence, what this release is about
### Highlights 4-5 max
<!- /modal -> the update dialog stops rendering here
<details> Also in this… grouped one-liners, collapsed
### Upgrade notes only when the reader must act; delete otherwise
— keep this OUTSIDE the <details>, it needs seeing
This file IS the release body — the compare link is appended automatically and
the per-commit list is deliberately not included. It is consumed at release
time and cleared afterwards, so anything left here ships.
RULES
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
6-commit release and an 80-commit release roughly the same height. If a
sixth feels essential, something else was not a highlight.
2. A highlight passes one of three tests:
- new capability — you can do something you could not before
- wall removed — something that blocked you now works
- surprising — behaviour changed in a way you should not meet alone
Polish, truncation, layout and invisible perf fail all three.
3. Commit count is NOT a test. The categories icon picker was two commits and a
real feature; `ui` was nine commits and almost entirely polish.
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
names are free-form — use whatever the release actually contains. It sits in
a <details> so a long release does not double the page; put the count in the
summary so the reader knows what they are opening. That number is written by
hand here and can drift — the update dialog computes its own from the same
bullets, so if the two disagree, this one is wrong. Note also that collapsed
text is not reliably reachable by browser find-in-page, which is the cost.
5. Write what changed for the user, not what changed in the code. Name the
symptom, not the fix: "the field went blank" beats "guarded a null deref".
6. Perf belongs in the tail WITH its numbers, so the people it affects
recognise themselves. "Performance improvements" tells nobody anything.
-->
---
**Full changelog:** [v2.11.0…v2.12.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.11.0...v2.12.0)
## 🎉 Wildcard Pipeline 2.11.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)
### ✨ What's new
A correctness fix for held modules on multi-frame runs, plus a set of tools for reconnecting workflow items to your library.
- **Held modules stay held for the whole run.** On a multi-frame sequence, a module set to **Hold across run** could quietly re-roll after the first frame — a held outfit or location would change mid-sequence — and every frame after the first filled the debug panel with constraint warnings. Held picks now carry through every frame, so constraints that depend on them keep applying, cross-node holds stay put, and the warning flood is gone.
- **Re-link items that lost their library connection.** When a re-import gives a library entry a new id, the copy in your workflow stops matching it: no drift, no **Edit library entry in SPA**, and saving creates a duplicate. **Push to library** now offers **Re-link to an existing library entry** when it finds one holding the same content — picking it automatically when there's exactly one match, or letting you choose from a list you can search by name *or* id. Works for bundles too.
- **Disconnected items now show as drifted** instead of looking like local-only rows. That means the **refresh drifted** button reconnects them in one click, and the library link, drift dot and refresh action all start working again.
- **"Reset to library snapshot" can no longer empty a bundle.** If the bundle wasn't linked to a library entry, or the snapshot came back empty, reset used to replace your modules with nothing. It now refuses and explains why, leaving the bundle untouched.
- **Importing content you already have offers to link it.** When an import contains an item identical to one already in your library under a different id, it's flagged **IN LIBRARY** with the matching entry named, and you can either link to it — references follow to the existing entry — or import the copy anyway. Nothing is linked or overwritten without you choosing.
- **Every edit modal shows the item's id.** A small chip in the header, click to copy — the same id that `@{...}` references, constraint sources and library rows all match on.
- **Nested references and variables render as chips** in derivation rule values and constraint exceptions, instead of showing raw text inside `{a|b}` braces. Exception values also get a proper picker of real option values rather than a free-text box.
- **Hover any reference chip** to see what it resolves to: its id, its sub-category filter in plain language, and how many options currently match — read live from your library, so the count moves when the library does.
- **Search by id.** Pasting an 8-character id into a search box now finds the item, in the manager lists and in the remap / reattach pickers. Previously only names matched, which was useless when two entries shared one.
- **Long values wrap** in the derivation override boxes instead of being clipped at the edge of the field.
---
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #11 from DumiFlex/fix/held-constraint-registration ([fd4c501](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd4c501)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
* docs(release-notes): summarise held-module fix + library re-link tooling ([9fb653f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9fb653f))
* chore(size): raise total bundle budget to 461 KB ([3f45b26](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3f45b26))
* fix(bundle): never let "Reset to library snapshot" silently empty a bundle ([78297c4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/78297c4))
* fix(chips): render nested refs + vars in derivation and exception surfaces ([a64e56d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a64e56d)), closes [label/#option](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/option)
* fix(engine): carry held modules' frame-0 constraint state to later frames ([1b32a8a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1b32a8a))
* fix(linkage): surface drift for a library-uuid module with an empty local hash ([bb5f6b8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb5f6b8))
* fix(search): match modules, bundles and templates by id as well as name ([44d0ca2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/44d0ca2))
* feat(chips): hover card on nested-ref and variable chips ([699deda](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/699deda))
* feat(editors): show module identity in edit-modal headers + wrap long values ([5a83e61](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a83e61))
* feat(import): link-to-existing decision for content duplicates (D3b core) ([e63be54](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e63be54)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
* feat(import): surface content duplicates as a resolvable conflict row (D3b) ([c268e75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c268e75))
* feat(relink): content-aware library re-link for detached bundle instances ([ecebd55](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ecebd55))
* feat(relink): content-aware library re-link for detached module rows ([a059b48](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a059b48))
* feat(relink): content-aware re-link match + apply helpers (pure) ([9ef1fb6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9ef1fb6))
</details>
## 🎉 Wildcard Pipeline 2.10.2
📖 [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)
### ✨ What's new
A bug-fix release for the in-app updater and name validation.
- **Fixed an updater downgrade.** On 2.10.0, clicking **Update Now** could install an *older* version — it asked ComfyUI Manager for "latest" against a stale cache. The updater now installs the exact new version and refuses to ever move backwards.
- **The update badge appears right away** after **Check now** — no page refresh needed.
- **Cleaner release notes** in the update dialog (proper formatting instead of raw `<details>` tags).
- **Names are validated as you rename them.** Renaming a sub-category or a variable now enforces the same rules as creating one, so invalid names (spaces, reserved characters) are caught immediately instead of failing later on save.
---
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #10 from DumiFlex/fix/updater-downgrade ([a2e93fe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a2e93fe)), closes [#10](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10)
* fix(manager): prevent updater downgrade, fix pill reactivity + release notes ([1e42f9b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1e42f9b))
</details>
## 🎉 Wildcard Pipeline 2.10.1
📖 [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>
* Merge pull request #9 from DumiFlex/fix/subcat-rename-validation ([74c7331](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/74c7331)), closes [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
* fix(manager): validate names on rename via shared validators (#7) ([00b735d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/00b735d)), closes [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
</details>
## 🎉 Wildcard Pipeline 2.10.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>
* Merge pull request #8 from DumiFlex/feat/manager-qol ([fe2423f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fe2423f)), closes [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8)
* feat(loop): add optional bypass toggle to seed-lock row ([d524d14](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d524d14))
* feat(loop): dashed border on bypassed edit-frame chips ([70283ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/70283ce))
* feat(loop): emit only non-bypassed frames with pinned identity ([9d372b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9d372b1))
* feat(loop): mirror bypass_frames in the ts config type ([da2239d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/da2239d))
* feat(loop): mirror the loop's bypassed frames in the seed list UI ([f670c3d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f670c3d))
* feat(loop): opt-in bypass column in the per-iteration modal ([651ca65](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/651ca65))
* feat(loop): parse bypass_frames in the loop config ([7aab09d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7aab09d))
* feat(loop): purple wash + dashed bar on bypassed seed rows ([ea4ed05](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ea4ed05))
* feat(loop): wire per-frame bypass through the loop widget ([7545ade](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7545ade))
* feat(manager): add checkOnLaunch update preference to uiStore ([d42b7ca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d42b7ca))
* feat(manager): add ComfyUI Manager update composable ([327e7cc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/327e7cc))
* feat(manager): add safe markdown renderer for release notes ([830776e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/830776e))
* feat(manager): add UpdateDialog with changelog + Manager-driven update ([290e7a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/290e7a8))
* feat(manager): add Updates settings card with check-on-launch + check now ([6c20f53](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6c20f53))
* feat(manager): bulk select + delete for exceptions + derivation rules (#11) ([ed10b88](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ed10b88)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
* feat(manager): bulk select + delete for fixed-values (#11) ([cbf102b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cbf102b)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
* feat(manager): drag-and-drop file import (#5) ([d4e6d0c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d4e6d0c)), closes [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5)
* feat(manager): move Target reach above the constraint rule matrix (#10a) ([d039364](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d039364)), closes [#10a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10a)
* feat(manager): open UpdateDialog from the topbar update pill ([936a139](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/936a139))
* feat(manager): per-branch collapse + collapse-all for derivation rules (#9) ([8ea1571](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8ea1571)), closes [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9)
* feat(manager): per-kind quick-select presets + import-tab mirror (#1) ([8912937](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8912937)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
* feat(manager): per-rule branch collapse + collapse rules/branches by default ([fcde3b3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fcde3b3))
* feat(manager): relevant + colour-coded bulk sub-category menus ([be70a5f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/be70a5f)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3) [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* feat(manager): rework release check to launch-once + manual, expose release notes ([bdcf85c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bdcf85c))
* feat(manager): sticky list headers across editors ([6802f11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6802f11))
* fix(loop): parse parity + edge coverage from final review ([bab87cd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bab87cd))
* fix(loop): seed list mirrors the loop's bypassed frames ([af95f08](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af95f08))
* fix(manager): count templates in the import loaded-bar total ([2295ce0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2295ce0))
* fix(manager): detect bundle collisions on import instead of blind-adding (#6) ([b54302d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b54302d)), closes [#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6)
* fix(manager): fresh dates for imported adds (#12) + live counts after import (#7) ([696711d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/696711d)), closes [#12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/12) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [#12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/12) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
* fix(manager): keep bulk-edit toolbar stuck with sticky card header ([6f69304](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f69304))
* fix(manager): resolve inline multi-pick {N-M$$sep$$…} in the test runner ([8dcef0f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8dcef0f))
* fix(manager): sticky-header subpixel bleed + confusing weight validation ([0bd4382](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0bd4382))
* test(engine): un-skip pipeline trace tests after handler migration ([7d2bd63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d2bd63))
</details>
## 🎉 Wildcard Pipeline 2.9.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(derivation-modal): absolute max-height for the rules scroll (revert flex) ([5d715fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5d715fb))
* fix(derivation-modal): pin rule cards so the rule list actually scrolls ([fc9d8a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fc9d8a8))
* fix(derivation-modal): scroll the rule LIST, not the whole modal ([6f664ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f664ce))
* fix(derivation-modal): scroll the rules region, pin header + footer ([e98aca3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e98aca3))
* fix(engine): derivation hold freezes each branch roll, not the branch choice ([df76abd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df76abd))
* fix(engine): hold the resolved VALUE across iterations, not just the seed ([58c5b27](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/58c5b27))
* fix(injector): keep custom pin labels with the socket on drag-reorder ([41fae1e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/41fae1e))
* fix(injector): persist custom pin labels durably across collapse + reload ([8568aa2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8568aa2))
* fix(injector): reconstruct collapsed-wire labels from data, not the dead stash ([0ede205](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0ede205))
* fix(injector): repair collapse-wire labels corrupted across a reload ([0281b8e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0281b8e))
* fix(injector): right-align the type pill on label-less ref/slot rows ([bf61f64](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bf61f64))
* docs(spa): describe hold as a value freeze (constrained picks + nested refs) ([1b3e4aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1b3e4aa))
* docs(spa): document per-iteration overrides, seed scope + lock-previous ([4a34c9b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4a34c9b))
* docs(spa): mention the right-click Hold toggle shortcut ([a738618](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a738618))
* docs(spa): note derivation supports Hold across run ([aba4e8c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/aba4e8c))
* feat(context): quick Hold toggle in the module row context menu ([8dcebdc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8dcebdc))
* feat(engine): derivation honors seed_scope=hold (freeze frame-0 output) ([ff6825c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff6825c))
</details>
## 🎉 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)
<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)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* fix(api): keep duplicate children when creating or updating bundles ([c1f45a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c1f45a3))
* fix(constraint): drop matrix scroll-container padding that leaked scrolled cells ([d3e25b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d3e25b7))
* fix(constraint): stop scrolled cells bleeding through the frozen matrix headers ([4098009](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4098009))
* fix(manager): decimal option probabilities and distinct sub-category group colors ([11d205b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11d205b))
* feat(constraint): group the canvas matrix axes like the SPA ([538db06](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/538db06))
* feat(manager): author and save bundles directly in the manager ([a77e60b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a77e60b))
* feat(manager): group constraint matrix axes with header chips and column bands ([c0f354c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c0f354c))
* feat(manager): label the matrix ungrouped axis run as "uncategorized" ([eac852f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eac852f))
* feat(manager): show uuid + option meta in reattach pickers to tell duplicates apart ([0bbf150](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0bbf150))
* feat(manager): type-to-filter all dropdowns ([b3537ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b3537ad))
</details>
## 🎉 Wildcard Pipeline 2.5.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(manager): one-time library repair for non-hex module/bundle ids ([02cc9f7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02cc9f7))
* fix(import): normalize non-hex module/bundle ids on import + download ([a1d0511](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a1d0511))
</details>
## 🎉 Wildcard Pipeline 2.4.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>
* Merge pull request #4 from DumiFlex/feat/inline-weights-pool-ui-debug ([ccb8392](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ccb8392)), closes [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4)
* fix(engine): apply N:: branch weights in simple inline {a|b|c} choices ([a951699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a951699))
* fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs ([d8c2072](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d8c2072))
* feat(engine): emit a constraint source-missing debug warning ([0e9e5db](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9e5db))
</details>
## 🎉 Wildcard Pipeline 2.3.1
📖 [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>
* Merge pull request #3 from DumiFlex/fix/instance-modal-scroll ([6a5a8d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6a5a8d3)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll ([a19e5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a19e5f4))
* fix(extension): scroll instance edit modals with pinned header + footer ([c43bff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c43bff0))
</details>
## 🎉 Wildcard Pipeline 2.3.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)
+42 -15
View File
@@ -44,9 +44,14 @@ ruff check . # Python lint
- **Toast singleton** — one Vue app mounted to body-level div renders all toasts pushed via `src/components/shared/toast-store.ts`. Every Context node import same store so toasts surface from anywhere. Position mirror ComfyUI native PrimeVue toast anchor (rect-relative to `.graph-canvas-container`).
- **Subgraph traversal** — `collectUpstreamVariables` / `collectUpstreamValues` / `findDownstreamAssemblers` cross subgraph boundaries via `-10` SubgraphInputNode + `-20` SubgraphOutputNode sentinel ids. Walkers expect `app.graph` (root); per-call `buildSubgraphParents` map handle step-out. Use `walkAllNodes(rootGraph)` for graph-wide scans (e.g. pre-run validation).
- **Subgraph badges** — `extension/subgraph-badge.ts` attach `LGraphBadge` (via `window.LGraphBadge`) to any SubgraphNode containing WP nodes. Badge surface worst inner conflict severity with human label (e.g. `missing $foo`, `2 conflicts`). Wire via `nodeCreated` + `loadedGraphNode` hooks — `beforeRegisterNodeDef` does NOT fire for SubgraphNodes.
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Rule types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning), `constraint_source_missing` / `constraint_target_missing` (warning — uuid not in catalog), `constraint_orphan_source` (warning — no source instance UPSTREAM), `constraint_orphan_target` (warning — the constraint's reach selector covers ZERO reachable downstream instances; per-selector, NOT count-aware — overlapping constraints may freely share an instance). Engine emits runtime warnings `constraint_never_applied` (the reach matched nothing at runtime) and `constraint_partial_reach` (a `next N` / `pick` reach matched fewer instances than requested).
- **Constraint reach (SP3)** — each constraint carries a `target_select` reach selector: `{mode: "first"|"next"|"all"|"pick", count?, picks?}`, default `all`. Every constraint whose reach covers a firing downstream target instance applies, COMBINED — per-pick × per-tag × per-constraint multiply, with `exclude` (factor 0) absorbing. No more one-shot/consumed-set: the engine counts per-constraint downstream hits in `ctx["__wp_constraint_hits__"]`. Reach is downstream-relative (counts from the constraint's own chain position) and cross-node + transitive (resolves `@{uuid}` at any depth). `pick` selects specific instances by `_uid` (direct top-level) or carrier `_uid` + option id (nested). The re-weight math is the pure `engine/modules/_constraint_math.py::combine_constraint_factor`, mirrored in TS at `src/manager/utils/constraint-math.ts` via the shared `tests/fixtures/constraint-corpus.json`. Published payloads using a non-default `target_select` stamp `schema_version = 4` (additive; community catalog v4).
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 250 KB. Pre-commit run `pnpm build:extension && pnpm size`. Raising budget require explicit PR discussion.
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Rule types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning), `constraint_source_missing` / `constraint_target_missing` (warning — uuid not in catalog), `constraint_orphan_source` (warning — no source instance UPSTREAM), `constraint_orphan_target` (warning — the constraint's reach selector covers ZERO reachable downstream instances; per-selector, NOT count-aware — overlapping constraints may freely share an instance), `constraint_excludes_all` (warning — some source option leaves a covered target with no option of weight > 0 and no fallback; `extension/constraint-dead-ends.ts`, checks one constraint at a time and leans quiet when unsure). Engine emits runtime warnings `constraint_never_applied` (the reach matched nothing at runtime) and `constraint_partial_reach` (a `next N` / `pick` reach matched fewer instances than requested).
- **Constraint reach (SP3)** — each constraint carries a `target_select` reach selector: `{mode: "first"|"next"|"all"|"pick", count?, picks?}`, default `all`. Every constraint whose reach covers a firing downstream target instance applies, COMBINED — per-pick × per-tag × per-constraint multiply, with `exclude` (factor 0) absorbing. Mode `only` (linked picks, schema v6) turns its row into an allow-list: when the source fires, a target with no rule of its own in that row is excluded (by value on an exception, by tag in a matrix row; a matrix `allow` cell is NOT a mention because editors show it as Neutral). No more one-shot/consumed-set: the engine counts per-constraint downstream hits in `ctx["__wp_constraint_hits__"]`. Reach is downstream-relative (counts from the constraint's own chain position) and cross-node + transitive (resolves `@{uuid}` at any depth). `pick` selects specific instances by `_uid` (direct top-level) or carrier `_uid` + option id (nested). The re-weight math is the pure `engine/modules/_constraint_math.py::combine_constraint_factor`, mirrored in TS at `src/manager/utils/constraint-math.ts` via the shared `tests/fixtures/constraint-corpus.json`. Published payloads using a non-default `target_select` stamp `schema_version = 4` (additive; community catalog v4).
- **Send-to-negative (schema v8)** — wildcard options, fixed values and combines carry an optional `negative` (same grammar as their value); derivation mode `negative` adds to a variable's negatives without writing it, and `negative_replace` swaps them for its value (test both with `isNegativeMode`). The engine files them per variable in `ctx["__wp_negatives__"]` (`engine/negatives.py`, cross-node key). Negatives FOLLOW USAGE: the Assembler's `negative` output only takes negatives of variables its template rendered; a write replaces a variable's negatives, append/prepend keep them. Negative text resolves on its own `key::neg` seed stream and refs inside it resolve quietly (no pick, no ref log, no hits, no chaining), so negatives never shift picks. Store an empty negative as absent. `$name.neg` / `$name.K.neg` reads a variable's negatives (`neg` rides the axis slot of the accessor grammar and is reserved: no `accepts` group may use it); it is not a read of `$name`, so it carries no negatives along. Both need v8.
- **Multi-action derivation clauses (schema v8)** — a branch or `else` runs its `action`, then each `extra_actions` entry in order (absent, never `[]`, for one action). Each action sees the previous one's write. Walk actions with `clause_actions` (Python) / `clauseActions` (TS); never read `.action` alone. Instance overrides key extra actions `"{bi}.{K}"` / `"else.{K}"` (`action_override_key` ≡ `actionOverrideKey`).
- **Wildcard fallback (schema v9)** — at most one non-null option per wildcard may carry `fallback: true` (stored only as `true`). It rolls like any option (weight, filters, toggles, constraints all apply; flagging never changes a normal draw) and is ALSO taken, without an rng draw, when nothing is live after weights + constraints (`engine/modules/_fallback.py:find_fallback`, run after filters + toggles) — top-level handler AND nested `ref_option_pool`. It replaces the `constraint_excludes_all_options` warning with an info `fallback_used`. Editors and probability helpers give it its normal share.
- **Model-aware pipelines** — `WP_ModelInfo` (`wp_nodes/model_info_node.py`) writes `$model_family` (from `model.model.model_config`'s class), `$model_variant` (file-name rules) and `$model_name`; the file name comes from following its `model` link through the hidden `prompt` (`engine/model_info.py::find_checkpoint_name`). Rules + stems mirror in TS at `src/extension/model-info.ts` via `tests/fixtures/model-variant-corpus.json`. Graph walkers treat it as a var source like the injector (kind `model`). Wildcard instance `match_variable` picks the option whose text equals that variable (else fallback, else roll + `match_variable_no_match`), which is how a variable becomes a constraint source. Workflow/instance state only, no schema bump.
- **Image Filter** — `WP_ImageFilter` (`wp_nodes/image_filter_node.py`, pure logic in `engine/image_filter.py`) is `is_input_list` with a FIXED slot set (no dynamic "any" outputs; `extra_1/2` are `MatchType`). A paused run waits in an `async execute` on the thread-safe `PENDING` store; the picker answers via `POST /wp/api/image-filter/answer` (`wp_api/image_filter.py`) after the `wp-image-filter` websocket event. "Stop" returns one `ExecutionBlocker(None)` PER output (a bare blocker does not block). Values batched like the frame's images are sliced (tensor dim 0, LATENT dict, CONDITIONING incl. `pooled_output`); everything else is kept/dropped with its frame. Picker answers may carry `edits` keyed `"f:i"` (`positive`/`negative` text, `mask` = PNG data URL whose ALPHA is the mask): with `same_shape` the first edited image of an item decides its prompt; an edited prompt is re-encoded only when the optional `clip` input is wired; once any mask is painted every output item gets a mask (incoming or empty). Send to Loop writes back through `editLoopConfig` (`widgets/context_loop.ts`).
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 468 KB. Pre-commit run `pnpm build:extension && pnpm size`. Raising budget require explicit PR discussion.
- **Coverage gate** — vitest v8 coverage with thresholds (lines/statements 50%, functions 40%, branches 65%). Anchored to current baseline; ratchet up alongside new tests.
- **Caching** — `WP_Context` + `WP_Debug` set `not_idempotent=True` so seed cycling always re-executes.
- **Conventional Commits** — enforced by commitlint. semantic-release read log to cut versions.
@@ -58,12 +63,13 @@ ruff check . # Python lint
## Frontend overview
- **Entry** `src/main.ts` → `js/main.js` (~1.6 KB gzip). Top-level await preload widget chunks so `getCustomWidgets` factories run sync (Promise return → `{widget: undefined}` and widget never mount). Register extension + lazy-import widget code only when ComfyUI hand matching node.
- **Lazy chunks** `src/widgets/{context,debug,assembler}.ts` — one mount glue per widget. Each `import()` become own asset chunk, plus sibling SFC chunk (`ContextWidget`, `DebugViewer`, `AssemblerHelper`).
- **Shared** `src/widgets/_shared.ts` expose `createDomWidgetHost`, JSON helpers (`parseWidgetJson`, `parseWidgetJsonWithRecovery` for corrupt-workflow recovery path), shared types. Largest shared chunk (`_shared`, ~33 KB gzip) is Vue runtime + plugin-vue helper, pulled in by every widget on first use.
- **Entry** `src/main.ts` → `js/main.js` (~4 KB gzip). One top-level `await import("./boot")` preload widget glue so `getCustomWidgets` factories run sync (Promise return → `{widget: undefined}` and widget never mount; ComfyUI does not await an async `getCustomWidgets`).
- **Boot chunk** `src/boot.ts` — everything registration + node creation need, re-exported from ONE module so it ship as ONE chunk (empty canvas = `main.js` + `boot-*.mjs`). Before, each preload was own `import()` and shared deps split out: 43 files. Only add what registration needs; anything reached by user action (modal, toast stack, subgraph badge) gets a `load*` helper in `boot.ts`, not an `import()` in `main.ts` (a chunk loaded from `main.ts` makes Rollup split vue + stores back out).
- **Widget glue** `src/widgets/*.ts` — one mount glue per widget, in the boot chunk. Each loads its SFC via `defineAsyncComponent` (`ContextWidget`, `DebugViewer`, `AssemblerHelper`, `RichTextInput`, …) so SFC chunks download only when a matching node exists.
- **Shared** `src/widgets/_shared.ts` expose `createDomWidgetHost`, JSON helpers (`parseWidgetJson`, `parseWidgetJsonWithRecovery` for corrupt-workflow recovery path), shared types. Lives in boot chunk with Vue runtime.
- **Graph + conflicts** `src/extension/{graph,conflicts,subgraph-badge,reactive,graph-events}.ts` — pure logic, no DOM. Imported by widgets, easy to unit-test isolated.
- **Reactivity** `extension/reactive.ts:reactiveFromGraph` — wires `onConnectionsChange` chain + 400ms polling fallback + `afterConfigureGraph` re-sync so widgets recompute on graph edits + workflow loads without flashing stale state.
- **Adding new widget**: (1) write SFC under `src/components/<name>/`, (2) add `src/widgets/<name>.ts` exposing `create(node, inputName)` that return `createDomWidgetHost(...)`, (3) wire in `src/main.ts` — either as `getCustomWidgets` entry (for inputs declared with matching widget-type) or via `beforeRegisterNodeDef → onNodeCreated` (for free-floating helpers). Use `import("./widgets/<name>")` so stay out of entry chunk + size gate keep holding.
- **Adding new widget**: (1) write SFC under `src/components/<name>/`, (2) add `src/widgets/<name>.ts` exposing `create(node, inputName)` that return `createDomWidgetHost(...)`, (3) re-export it from `src/boot.ts` and wire in `src/main.ts` — either as `getCustomWidgets` entry (for inputs declared with matching widget-type) or via `beforeRegisterNodeDef → onNodeCreated` (for free-floating helpers). SFC via `defineAsyncComponent`, never static import, so boot chunk stay small.
## Schema versioning + lazy migration machine
@@ -81,11 +87,19 @@ Spec: `D:\Desktop\Wildcard-Pipeline-Community\docs\superpowers\specs\2026-06-05-
- `tolerant_drift_status TEXT` — `none` | `tolerant` | `strict`. Set by parseTolerantAsCurrentShape during install.
- `schema_migrated_at TEXT` — wall-clock stamp of last lazy migration step.
**Which number goes where** (three constants, one job each — don't collapse them):
- **`CURRENT_SCHEMA_VERSION` = 2** (`src/manager/import-export/migrations.ts`, mirrored in `engine/migrations/__init__.py`): head of the migration chain. Stays 2 because v3–v9 are additive and need no migrator.
- **`MAX_KNOWN_SCHEMA_VERSION` = 9** (same two files): the highest version this runtime reads + writes natively. Advertised to the community as `window.__wpcRuntime.schemaVersion` (`src/manager/main.ts`) and the ceiling above which `migrateImportEnvelope` refuses a pack.
- **Content stamp** (`schemaVersionForPayload` in `src/manager/import-export/single-row-publish.ts`): what publish sends — the LOWEST version the payload's features need (2 baseline, 3 SP2b range/`~` grammar, 4 non-default constraint `target_select`, 5 a wildcard `accepts` tag axis, 6 a constraint `only` rule, 7 a derivation branch with AND/OR groups or `is_empty`/`is_not_empty`, 8 a non-empty `negative`, a derivation "Add to negative" / "Replace negative" action, `extra_actions` or a `$name.neg` read, 9 a wildcard option flagged `fallback`).
A pack arriving from the community keeps the stamp the server stored for it (`CommunityDownload.schema_version`, fed to `wrapAsEngineExport` / the dependency downloader) so install migrates it from where it actually is. Never stamp a received pack with a constant. Local round-trips of live rows (e.g. `BundleEditor` extract) stamp `MAX_KNOWN_SCHEMA_VERSION`.
**The machine**:
- **Validator registry** (`src/manager/import-export/validators/`): one Zod validator per known `schema_version`. Strict at `≤ CURRENT_SCHEMA_VERSION`; future versions go through `parseTolerantAsCurrentShape` (additive-strip).
- **Validator registry** (`src/validators/index.ts:getValidator`): one Zod validator per shape-changing `schema_version` (v1, v2). v3–v9 didn't change a shape, so they validate against v2. Tolerant mode (`.strip()`) is for installing newer payloads (`parseTolerantAsCurrentShape`).
- **`parseTolerantAsCurrentShape(raw, currentVersion)`** (`src/manager/import-export/tolerant-parse.ts`): strips fields the current shape doesn't know, returns the projection that matches `currentVersion`. Used at install when source `schema_version > CURRENT`.
- **`migratePayload(payload, from, to)`** (`src/manager/import-export/migration-machine.ts`): forward-only chain. Each step is a registered function in the migrators map. `from > to` is illegal — no downgrades.
- **`migratePayload(payload, from, to)`** (`src/manager/import-export/migrations.ts`, beside the envelope-level `migrateImportEnvelope`): forward-only chain. Each step is a registered function in the migrators map. `from > to` is illegal — no downgrades.
- **`lazy_migrate_row(row, currentVersion, migrators, validators)`** (`engine/db/lazy_migrate.py`): reads `original_payload_json` (or `payload` if absent), walks the migrator chain to `currentVersion`, validates, writes back. Bulk-at-boot runner is eager iteration of this routine.
- **Install decision tree** (`src/manager/import-export/install.ts`): branches on `source_schema_version` vs `CURRENT_SCHEMA_VERSION`:
- `=`: strict validate + insert.
@@ -93,20 +107,30 @@ Spec: `D:\Desktop\Wildcard-Pipeline-Community\docs\superpowers\specs\2026-06-05-
- `>` and tolerant-only diff: parseTolerantAsCurrentShape, mark `tolerant_drift_status='tolerant'`, insert.
- `>` and breaking-future diff (per catalog `is_breaking_from_previous` AND-fold across the interval): refuse install with a clear error.
**Server probe**: `engine-export-wrap.ts:ENGINE_SCHEMA_VERSION` is the sister's pinned CURRENT — it ships in the sister bundle, not fetched from server. The host bridge install path reads it from `window.__wpcRuntime.schemaVersion`. Server-first deploy ordering (see community CLAUDE.md) means the community catalog always reflects shapes ≥ sister's CURRENT.
**Server probe**: the host bridge advertises `MAX_KNOWN_SCHEMA_VERSION` as `window.__wpcRuntime.schemaVersion`; it ships in the sister bundle, not fetched from server. The community embed refuses to install a pack stamped above it. Server-first deploy ordering (see community CLAUDE.md) means the community catalog always reflects shapes ≥ sister's MAX_KNOWN.
### Bumping `schema_version` (the proper way)
When a payload shape change lands (not a row-column change — those don't bump schema):
1. **Add the new shape's validator.** New file under `src/manager/import-export/validators/v<N>.ts` exporting a Zod schema. Register in `validators/index.ts`.
2. **Add the migrator step.** In `src/manager/import-export/migration-machine.ts`, register `migrators[N-1 → N]: (payload) => ...`. Forward-only. The function is pure — no side effects.
3. **Add fixtures.** `src/manager/__tests__/import-export/fixtures/v<N>/` — at minimum: a `valid-strict.json`, a `from-v<N-1>-migrated.json` round-trip case, and one `breaking-shape.json` if the bump is breaking.
4. **Bump the constant.** `CURRENT_SCHEMA_VERSION` lives in the validator registry. Bump it to `N`. Also bump `ENGINE_SCHEMA_VERSION` in `src/manager/components/engine-export-wrap.ts`.
**Additive bump** (an optional field, new enum value or new text grammar an old runtime can safely lack — v3 through v9 all were):
1. **Add the version constant** next to `TAG_AXES_SCHEMA_VERSION` / `CONSTRAINT_ONLY_SCHEMA_VERSION` in `migrations.ts` (and `engine/migrations/__init__.py`), with a comment saying what triggers it.
2. **Teach `schemaVersionForPayload`** AND its Python mirror `engine/migrations/stamping.py` to return it only when the payload actually uses the feature (structural walk, bundle children + instance overrides included), and add cases to `src/manager/__tests__/import-export/publish-stamping.test.ts` + `tests/engine/test_schema_stamping.py`.
3. **Bump `MAX_KNOWN_SCHEMA_VERSION`** in `migrations.ts` AND `engine/migrations/__init__.py`, and update the drift guard in `schema-version-fork.test.ts` to exercise the new highest stamp.
4. **Make sure the strict validator keeps the new field** (it strips unknown keys) — `src/validators/module/v2.ts` + the engine-parity test.
5. **Catalog row** — step 7 below. `CURRENT_SCHEMA_VERSION` does not move.
**Shape-changing bump** (needs a migrator):
1. **Add the new shape's validator.** New file under `src/validators/module/v<N>.ts` (and `bundle/` if needed). Register every subtype at `N` in `src/validators/index.ts`.
2. **Add the migrator step.** In `src/manager/import-export/migrations.ts`, add to `MIGRATION_CHAIN` (envelope) and/or `registerMigrator` (per row), and mirror it under `engine/migrations/`. Forward-only. The function is pure — no side effects.
3. **Add fixtures.** Under `src/validators/fixtures/v<N>/` — at minimum a strict-valid module per subtype, plus a migrated-from-v<N-1> round-trip case in `src/manager/__tests__/import-export/migration-machine.test.ts`.
4. **Bump the constants.** `CURRENT_SCHEMA_VERSION` and `MAX_KNOWN_SCHEMA_VERSION` to at least `N`, in both `migrations.ts` and `engine/migrations/__init__.py`.
5. **Write the migration test.** `tests/engine/db/test_lazy_migrate.py` should cover v<N-1>→v<N> round-trip + the breaking-future path if applicable.
6. **Run `pytest tests/engine/ -q && pnpm test` until green.** Sister pre-commit hook (~2 min) also runs lint + typecheck + build + size; expect that wait.
7. **Coordinate the community-side catalog row.** Community web's `schema_catalog` table gets the matching `(version, is_breaking_from_previous, notes)` row + a deploy that lands BEFORE sister's PR merges. See community CLAUDE.md "Bumping the schema" for the server side.
8. **Sister deploys after server.** Once catalog HEAD ≥ sister's CURRENT, sister can safely refuse breaking-future shapes.
8. **Sister deploys after server.** Once catalog HEAD ≥ sister's MAX_KNOWN, sister can safely publish the new stamp and refuse breaking-future shapes.
### Validator ↔ engine parity (DON'T skip)
@@ -122,6 +146,9 @@ this:
shape to `src/validators/fixtures/engine-parity/*.json`.
- `src/validators/__tests__/engine-parity.test.ts` asserts the **strict**
community validator accepts every one of those fixtures.
- `tests/test_engine_parity_fixtures.py` re-runs the dump in memory and fails
pytest when a committed fixture is stale, so forgetting the script is
caught in CI.
**Any time you change an engine module payload shape** (a `*_handler.py`
`validate_payload`, or what the editor stores), run
+112 -107
View File
@@ -1,156 +1,161 @@
<h1 align="center">
<img src="public/images/favicon.svg" width="40" align="center" alt=""/>
<img src="public/images/favicon.svg" width="40" align="top" alt=""/>
&nbsp;Wildcard Pipeline
</h1>
<p align="center">
<em>Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, loops, and a persistent module library.</em>
<em>Random prompts for ComfyUI that actually make sense together.</em>
</p>
<p align="center">
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/actions"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/DumiFlex/ComfyUI-Wildcard-Pipeline/ci.yml?branch=main&label=ci"></a>
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/releases"><img alt="Release" src="https://img.shields.io/github/v/release/DumiFlex/ComfyUI-Wildcard-Pipeline?include_prereleases"></a>
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/DumiFlex/ComfyUI-Wildcard-Pipeline"></a>
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki"><img alt="Wiki" src="https://img.shields.io/badge/docs-wiki-blue"></a>
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/actions"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/DumiFlex/ComfyUI-Wildcard-Pipeline/ci.yml?branch=main&label=ci&logo=githubactions&logoColor=white"></a>
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/releases"><img alt="Release" src="https://img.shields.io/github/v/release/DumiFlex/ComfyUI-Wildcard-Pipeline?include_prereleases&logo=github&logoColor=white"></a>
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/DumiFlex/ComfyUI-Wildcard-Pipeline?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Im0xNiAxNiAzLTggMyA4Yy0uODcuNjUtMS45MiAxLTMgMXMtMi4xMy0uMzUtMy0xWiIvPjxwYXRoIGQ9Im0yIDE2IDMtOCAzIDhjLS44Ny42NS0xLjkyIDEtMyAxcy0yLjEzLS4zNS0zLTFaIi8%2BPHBhdGggZD0iTTcgMjFoMTAiLz48cGF0aCBkPSJNMTIgM3YxOCIvPjxwYXRoIGQ9Ik0zIDdoMmMyIDAgNS0xIDctMiAyIDEgNSAyIDcgMmgyIi8%2BPC9zdmc%2B"></a>
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki"><img alt="Wiki" src="https://img.shields.io/badge/docs-wiki-blue?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMiA3djE0Ii8%2BPHBhdGggZD0iTTMgMThhMSAxIDAgMCAxLTEtMVY0YTEgMSAwIDAgMSAxLTFoNWE0IDQgMCAwIDEgNCA0IDQgNCAwIDAgMSA0LTRoNWExIDEgMCAwIDEgMSAxdjEzYTEgMSAwIDAgMS0xIDFoLTZhMyAzIDAgMCAwLTMgMyAzIDMgMCAwIDAtMy0zeiIvPjwvc3ZnPg%3D%3D"></a>
<a href="https://discord.gg/BFYR9WQdVR"><img alt="Discord" src="https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white"></a>
<a href="https://wp.dumiflex.dev/?utm_source=github&amp;utm_medium=readme"><img alt="Community" src="https://img.shields.io/badge/community-modules-8A63D2?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZpbGw9IndoaXRlIj48cGF0aCBkPSJtNTEyLDJjLTUzLjYsMC05OC40NywzOC4xNi0xMDguODQsODguNzMtNzUuMDcsMTkuMjktMTQzLjMyLDU4LjI2LTE5OC43NCwxMTMuNjgtNTUuNDIsNTUuNDItOTQuMzksMTIzLjY4LTExMy42OCwxOTguNzQtNTAuNTgsMTAuMzgtODguNzMsNTUuMjQtODguNzMsMTA4Ljg0czM4LjE2LDk4LjQ3LDg4LjczLDEwOC44NGMxMy42Miw1My4xNCwzNy4wMywxMDIuNjQsNjkuNzcsMTQ3LjQ3LDM3LjE0LDUwLjg2LDg1LjgzLDkzLjg2LDE0MC44MSwxMjQuMzMsNS41NCwzLjA4LDExLjU2LDQuNTQsMTcuNDgsNC41NCwxMi43LDAsMjUuMDItNi43MiwzMS42MS0xOC42MSw5LjY4LTE3LjQ0LDMuMzgtMzkuNDItMTQuMDctNDkuMDktODUuMzItNDcuMzItMTQ3LjIyLTEyNS43MS0xNzMuMzgtMjE4LjIxLDM2LjMtMTguMjksNjEuMjctNTUuOTIsNjEuMjctOTkuMjhzLTI0Ljk0LTgwLjk3LTYxLjIzLTk5LjI3YzM0LjIyLTExOS45NiwxMjkuNzktMjE1LjUyLDI0OS43NC0yNDkuNzQsMTguMywzNi4yOSw1NS45Miw2MS4yMyw5OS4yNyw2MS4yMyw2MS4yNywwLDExMS4xMS00OS44NCwxMTEuMTEtMTExLjExUzU3My4yNywyLDUxMiwyWk0xMTMuMTEsNDczLjExYzIxLjQ0LDAsMzguODksMTcuNDQsMzguODksMzguODksMCwyMC4xLTE1LjMyLDM2LjY5LTM0LjkxLDM4LjY5aC0uMDFjLS4xOC4wMi0uMzYuMDMtLjUzLjA0LTEuMTMuMS0yLjI4LjE2LTMuNDMuMTYtMjEuNDQsMC0zOC44OS0xNy40NC0zOC44OS0zOC44OXMxNy40NC0zOC44OSwzOC44OS0zOC44OVptMzk4Ljg5LTMyMS4xMWMtMjEuNDQsMC0zOC44OS0xNy40NC0zOC44OS0zOC44OXMxNy40NC0zOC44OSwzOC44OS0zOC44OSwzOC44OSwxNy40NCwzOC44OSwzOC44OS0xNy40NCwzOC44OS0zOC44OSwzOC44OVoiLz48cGF0aCBkPSJtOTMzLjI3LDQwMy4xNmMtMTMuNjItNTMuMTQtMzcuMDMtMTAyLjY0LTY5Ljc3LTE0Ny40Ny0zNy4xNC01MC44Ni04NS44My05My44Ni0xNDAuODEtMTI0LjMzLTE3LjQ0LTkuNjgtMzkuNDItMy4zOC00OS4wOSwxNC4wNy05LjY4LDE3LjQ0LTMuMzgsMzkuNDIsMTQuMDcsNDkuMDksODUuMzIsNDcuMzIsMTQ3LjIyLDEyNS43MSwxNzMuMzgsMjE4LjIxLTM2LjMsMTguMjktNjEuMjcsNTUuOTItNjEuMjcsOTkuMjhzMjQuOTQsODAuOTcsNjEuMjMsOTkuMjdjLTM0LjIyLDExOS45Ni0xMjkuNzksMjE1LjUyLTI0OS43NCwyNDkuNzQtMTguMy0zNi4yOS01NS45Mi02MS4yMy05OS4yNy02MS4yMy02MS4yNywwLTExMS4xMSw0OS44NC0xMTEuMTEsMTExLjExczQ5Ljg0LDExMS4xMSwxMTEuMTEsMTExLjExYzUzLjYsMCw5OC40Ny0zOC4xNiwxMDguODQtODguNzMsNzUuMDctMTkuMjksMTQzLjMyLTU4LjI2LDE5OC43NC0xMTMuNjgsNTUuNDItNTUuNDIsOTQuMzktMTIzLjY4LDExMy42OC0xOTguNzQsNTAuNTgtMTAuMzgsODguNzMtNTUuMjQsODguNzMtMTA4Ljg0cy0zOC4xNi05OC40Ny04OC43My0xMDguODRabS00MjEuMjcsNTQ2LjYyYy0yMS40NCwwLTM4Ljg5LTE3LjQ0LTM4Ljg5LTM4Ljg5czE3LjQ0LTM4Ljg5LDM4Ljg5LTM4Ljg5LDM4Ljg5LDE3LjQ0LDM4Ljg5LDM4Ljg5LTE3LjQ0LDM4Ljg5LTM4Ljg5LDM4Ljg5Wm0zOTguODktMzk4Ljg5Yy0yMS40NCwwLTM4Ljg5LTE3LjQ0LTM4Ljg5LTM4Ljg5LDAtMjAuMDMsMTUuMjItMzYuNTgsMzQuNzItMzguNjcuMzgtLjA0Ljc3LS4wOCwxLjE2LS4xMWguMDJjLjk5LS4wOCwxLjk5LS4xMSwyLjk5LS4xMSwyMS40NCwwLDM4Ljg5LDE3LjQ0LDM4Ljg5LDM4Ljg5cy0xNy40NCwzOC44OS0zOC44OSwzOC44OVoiLz48cGF0aCBkPSJtNzM1LjAyLDUxNy41NmMtMTUzLjgyLDMwLjg3LTE4Ni41OSw2My42NC0yMTcuNDYsMjE3LjQ2LTEuMjIsNi4wOC05LjksNi4wOC0xMS4xMiwwLTMwLjg3LTE1My44Mi02My42NC0xODYuNTktMjE3LjQ2LTIxNy40Ni02LjA4LTEuMjItNi4wOC05LjksMC0xMS4xMiwxNTMuODItMzAuODcsMTg2LjU5LTYzLjY0LDIxNy40Ni0yMTcuNDYsMS4yMi02LjA4LDkuOS02LjA4LDExLjEyLDAsMzAuODcsMTUzLjgyLDYzLjY0LDE4Ni41OSwyMTcuNDYsMjE3LjQ2LDYuMDgsMS4yMiw2LjA4LDkuOSwwLDExLjEyWiIvPjxwYXRoIGQ9Im00MzQuMDYsNjY1Ljg1Yy01MS4wOCwxMC4yNS02MS45NiwyMS4xMy03Mi4yMiw3Mi4yMi0uNDEsMi4wMi0zLjI5LDIuMDItMy42OSwwLTEwLjI1LTUxLjA4LTIxLjEzLTYxLjk2LTcyLjIyLTcyLjIyLTIuMDItLjQxLTIuMDItMy4yOSwwLTMuNjksNTEuMDgtMTAuMjUsNjEuOTYtMjEuMTMsNzIuMjItNzIuMjIuNDEtMi4wMiwzLjI5LTIuMDIsMy42OSwwLDEwLjI1LDUxLjA4LDIxLjEzLDYxLjk2LDcyLjIyLDcyLjIyLDIuMDIuNDEsMi4wMiwzLjI5LDAsMy42OVoiLz48cGF0aCBkPSJtNzM4LjA3LDM2MS44NGMtNTEuMDgsMTAuMjUtNjEuOTYsMjEuMTMtNzIuMjIsNzIuMjItLjQxLDIuMDItMy4yOSwyLjAyLTMuNjksMC0xMC4yNS01MS4wOC0yMS4xMy02MS45Ni03Mi4yMi03Mi4yMi0yLjAyLS40MS0yLjAyLTMuMjksMC0zLjY5LDUxLjA4LTEwLjI1LDYxLjk2LTIxLjEzLDcyLjIyLTcyLjIyLjQxLTIuMDIsMy4yOS0yLjAyLDMuNjksMCwxMC4yNSw1MS4wOCwyMS4xMyw2MS45Niw3Mi4yMiw3Mi4yMiwyLjAyLjQxLDIuMDIsMy4yOSwwLDMuNjlaIi8%2BPC9zdmc%2B"></a>
</p>
<p align="center">
<img src="public/images/docs/flow-intro.svg" alt="Modules resolve inside WP Context → named $variables → WP Prompt Assembler → CLIP" />
<img src="public/images/docs/flow-intro.svg" alt="Modules resolve inside WP Context, which emits named $variables that fill the WP Prompt Assembler template, which CLIP Encode turns into conditioning" />
</p>
## What it does
Plain wildcards pick every part of a prompt on its own, so you get a snowy beach
at noon under the stars. Wildcard Pipeline builds the prompt step by step, and
each step can see what the earlier ones picked. If the weather comes out as rain,
the mood can lean gloomy and the lighting can follow. Every Generate gives you a
fresh prompt that still hangs together.
Stock ComfyUI prompt nodes either take a single literal string or pick from a flat wildcard file. **Wildcard Pipeline** lets each module read what the previous modules picked — so `$mood` reacts to `$weather` via a constraint, a `combine` interpolates `$style $subject` into `$scene`, a derivation flips `$accent` based on `$mood`, and your final prompt is a coherent multi-element composition instead of a Frankenstein concatenation.
## Install
**One Generate. One coherent prompt. Re-roll until you stop laughing.**
- **ComfyUI Manager (recommended):** search for **Wildcard Pipeline** by
**dumiflex**, click **Install** and restart ComfyUI.
- **By hand:** download the zip from the
[latest release](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/releases/latest),
unzip it into `ComfyUI/custom_nodes/` and restart ComfyUI.
## Highlights
<details>
<summary>Installing from source</summary>
- **Module stack inside a Context node** — drop in `wildcard`, `fixed_values`, `combine`, `derivation`, `constraint`, `bundle` modules and they each publish a `$variable` to the chain.
- **Constraints with carriers** — re-weight downstream wildcards based on what upstream picked. Matrix + per-option exceptions. Carrier-via-nested-ref so a constraint can reach a wildcard nested inside another wildcard's option.
- **Loops + per-iteration seeds** — `WP Context Loop` runs your whole chain N times from a single Generate; `WP Seed List` pairs N unique sampler seeds with the N prompts so every output is distinct.
- **Persistent library** — a manager SPA backed by SQLite stores reusable modules, frozen bundles, prompt templates, and a category tree. Browse / edit / fork / push from the canvas.
- **Inspector** — `WP Debug` shows the post-run snapshot, per-module trace, per-wildcard picks, and the conflict-scanner warnings.
- **Type-coercion helpers** — `WP Var → Int / Float / Bool` parse typed values out of any `$variable` so wildcards can drive image width, step count, sampler cfg, conditional switches.
A git clone has no built frontend, so you need Node.js and pnpm:
## Installation
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline
cd ComfyUI-Wildcard-Pipeline
pnpm install
pnpm build
```
### ComfyUI Manager (recommended)
1. Open the **ComfyUI Manager** in your browser.
2. Search for **Wildcard Pipeline** by **dumiflex**.
3. Click **Install** and restart ComfyUI.
### Manual installation
1. Navigate to your ComfyUI `custom_nodes` directory.
2. Clone the repository:
```bash
git clone https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline
```
3. Install the Python dependencies:
```bash
pip install -e .
```
4. Build the frontend assets (requires Node.js and pnpm):
```bash
pnpm install
pnpm run build
```
5. Restart ComfyUI.
</details>
## Quick start
1. Open the Wildcard Pipeline manager (sidebar → **Wildcards** → **+ New**). Name `subject`, variable binding `$subject`, three options: `a cat`, `a dog`, `a fox` at weight 1 each. Save.
2. On the canvas, drop `WP Context` → `WP Prompt Assembler` → `CLIP Text Encode` → `KSampler`. Add your wildcard to the context. Type `a $subject` into the assembler template. Queue.
1. Open the **Wildcard Pipeline** manager from the ComfyUI sidebar and create a
wildcard called `subject` with three options: `a cat`, `a dog`, `a fox`.
2. On the canvas, chain **WP Context** → **WP Prompt Assembler** →
**CLIP Text Encode**. Add your wildcard to the Context node and type
`a photo of $subject` into the Assembler.
3. Queue a few times. Each run picks a new subject.
Each Generate rolls a fresh subject. Full walkthrough: [Quick Start wiki page](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start).
The [Quick Start wiki page](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start)
walks through a fuller example.
## The nodes
## How it works
You stack **modules** inside a **WP Context** node. Each module sets one or more
`$variables`, and the **WP Prompt Assembler** drops them into your template.
| Module | What it does |
|---|---|
| **Wildcard** | Picks one option from a weighted list. |
| **Fixed Values** | Sets variables to values you choose, like a style or quality tags. |
| **Combine** | Builds one variable out of others, e.g. `$style $subject`. |
| **Derivation** | If/else rules: "if `$weather` is rain AND `$time` is night, set `$lighting` to neon". Conditions can be grouped with AND / OR. |
| **Constraint** | Makes an earlier pick change the odds of a later one, or rule options out entirely. |
| **Bundle** | A saved group of modules you can drop in as one piece. |
Options can point at other modules with `@name`, so a wildcard can nest another
one. If the module you want doesn't exist yet, pick **Placeholder** from the `@`
list and connect it later.
### Nodes
| Node | What it does |
|---|---|
| `WP Context` | Holds a module stack, emits a resolved `$variable` context. |
| `WP Context Loop` | Runs the downstream chain N times in one click; emits per-iteration `$iteration` + `$iteration_total`. |
| `WP Context Injector` | Lifts any ComfyUI node output (multiline String, INT, etc.) into a named `$variable`. |
| `WP Seed List` | Emits a list of N derived seeds — one per loop iteration — so every (prompt, seed) pair is unique. |
| `WP Prompt Assembler` | Fills `$var` placeholders in a template string. Supports `{a\|b\|c}` inline picks + missing-var detection. |
| `WP Prompt Cleaner` | Rule-based prompt cleanup: whitespace, punctuation, exact + fuzzy dedupe, blocklist. |
| `WP Debug` | Read-only inspector — Snapshot / Trace / Picks / Warnings tabs. |
| `WP Var → Int / Float / Bool` | Parse typed values out of any `$variable` to drive ComfyUI INT / FLOAT / BOOLEAN inputs. |
| **WP Context** | Holds your module stack and outputs the picked variables. Chain several together. |
| **WP Prompt Assembler** | Fills `$variables` in a template. Also supports inline `{a\|b\|c}` picks. |
| **WP Context Loop** + **WP Seed List** | Run the whole chain N times from one Generate, each with its own prompt and seed, or sweep every combination of chosen wildcard options. |
| **WP Context Injector** | Turns any ComfyUI output (text, a number) into a `$variable`. |
| **WP Model Info** | Reads the loaded checkpoint into `$model_family` / `$model_variant` (Pony, Illustrious, …) so rules can pick LoRAs and tags per model. |
| **WP Image Filter** | Pauses the run so you pick which images go on to the upscaler. Their latent, conditioning, prompt text and Context are filtered with them, and you can edit a prompt or paint a mask before they go on. |
| **WP Prompt Cleaner** | Tidies the final prompt: spacing, stray commas, empty `()`, nested weights, LoRA tags, duplicates, a blocklist. |
| **WP Debug** | Shows what happened during a run (see below). |
| **WP Var → Int / Float / Bool** | Use a variable to drive a number or switch, like image size or steps. |
Full node reference: [Nodes wiki page](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Nodes).
Most nodes have a help page in ComfyUI's node info panel, and the
[wiki](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) covers
[nodes](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Nodes),
[modules](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Modules) and
[concepts](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Concepts)
in depth.
## The modules
## The manager
| Module | Writes | Role |
|---|---|---|
| **Wildcard** | `$<name>` | Weighted random pick from a pool. The core building block. |
| **Fixed Values** | `$<name>` (× N) | Static `name → value` bindings — style, quality boosters, negative-prompt fragments. |
| **Combine** | `$<name>` | Templated string that interpolates earlier `$vars` into a single output `$var`. |
| **Derivation** | `$<name>` | IF / ELIF / ELSE rules that read picked `$vars` and rewrite others. |
| **Constraint** | *(none)* | One-shot re-weight of a downstream wildcard's pool based on an upstream pick. |
| **Bundle** | *(group)* | Frozen group of modules — drop into any Context as a reusable unit. |
| **Template** | *(asm side)* | Saved Prompt Assembler template string in the library. |
Full module reference: [Modules wiki page](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Modules).
## Looping a chain — one click, N coherent prompts
Open it from the ComfyUI sidebar. It's where your modules live between workflows.
<p align="center">
<img src="public/images/docs/flow-loop.svg" alt="WP Context Loop + WP Seed List → KSampler — both lists fan out in lockstep" />
<img src="public/images/docs/spa_manager.png" alt="Wildcard Pipeline manager dashboard with module counts, quick create buttons and recently opened items" />
</p>
Drop `WP Context Loop` before your `WP Context` and pair it with `WP Seed List` for distinct sampler seeds. Both lists fan out in lockstep so iteration N's prompt always pairs with seed N. Each iteration carries its own `$iteration` / `$iteration_total` for in-template labelling (`frame 1 of 4 — a fox`). See [Concepts → Seeds and loops](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Concepts#seeds-and-loops).
- **Library:** create, edit and search your modules, bundles and prompt
templates. The **Tags** page groups them into collections, and you can rename
or merge a tag across the whole library.
- **Test Runner:** try a set of modules over many seeds without generating
images. See how often each option comes up and which rules fired, save a
baseline, and after an edit see exactly which outputs changed.
- **Community:** browse and install modules other people have shared on the
[community site](https://wp.dumiflex.dev/?utm_source=github&utm_medium=readme), and publish your own.
- **Import / Export:** move modules between machines as JSON files, or bring in
wildcard packs from A1111 or Forge on the **Wildcard packs** tab. Dynamic Prompts,
PPP and Impact Pack `.txt`, `.yaml` and `.json` files (or a `.zip` of them) become
wildcards, with `__references__` turned into links. A folder of related lists merges
into one wildcard whose options are tagged by list, with a tag group per folder level.
You review the plan first (each wildcard's role, what was merged and how exactly it
carries over), and the pack's entry points are filed into bundles per folder.
- **Documentation:** the full guide, built into the app.
## The manager (SPA)
## Checking a run
A dedicated single-page app for browsing + editing the library. Open it from the ComfyUI sidebar.
Connect **WP Debug** to a Context node and generate. It shows:
<p align="center">
<img src="public/images/docs/spa_manager.png" alt="Wildcard Pipeline manager — Dashboard view with module counts, Quick Create row, and a list of recently opened library entries" />
</p>
- **Variables:** every value, and which step set it.
- **Trace:** each step, and why it did what it did: which derivation rule fired,
a wildcard's odds and what re-weighted them, where a nested pick came from.
- **Warnings:** anything that went wrong, linked to the step that caused it.
- **Raw:** the underlying data, for bug reports.
- **Library** — wildcards / fixed values / combines / derivations / constraints / bundles / templates / categories. Filter, search, bulk-tag, favorite.
- **Test Runner** — resolve any module against the engine N times and inspect per-rule fire rates / per-option pick rates.
- **Import / Export** — versioned JSON exports with conflict resolution (rename / overwrite / skip per row).
- **Cascade impact dialog** — deleting a module shows you every bundle + constraint + combine that references it before you confirm.
- **Documentation tab** — full in-app docs covering every node + module + concept, with worked examples.
## Help and sharing
## Inspecting a run
[Discord](https://discord.gg/BFYR9WQdVR) is the place for all of it: there are
channels for questions, bug reports, feature requests and showing what you made.
You can also report bugs on [GitHub Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues).
Wire a `WP Debug` node off any Context output. After Generate it fills with a tabbed snapshot of what flowed through that point:
Made something you like? Post your images and workflows on Discord, and publish
your modules on the [community site](https://wp.dumiflex.dev/?utm_source=github&utm_medium=readme) so others can
install them from the manager's **Community** page.
- **Snapshot** — final `$variable → value` map.
- **Trace** — per-module execution log: what each ran, what it wrote, where the seed came from.
- **Picks** — which wildcard option was picked, its weight, its sub-category.
- **Warnings** — runtime warnings (constraint never fired, missing variable, etc.).
## Privacy
See [Nodes → WP Debug](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Nodes#wp-debug).
## Documentation
- 📖 **[GitHub Wiki](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki)** — Home / Quick Start / Nodes / Modules / Concepts
- 🖥️ **In-app docs** — sidebar → Documentation tab inside the manager SPA (every concept, every module, every node, with worked examples)
- 💬 **[Discord](https://discord.gg/BFYR9WQdVR)** — chat, show-and-tell, fastest path to a response
- 💭 **[GitHub Discussions](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/discussions)** — long-form questions, ideas, design threads
- 🐛 **[Issue tracker](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)** — bug reports + feature requests
## Status
Active development. Versioned releases with [semantic-release](https://github.com/semantic-release/semantic-release) — every merged commit on `main` either ships a release or stays as `next` until the next breaking / feature change rolls one. Bundle-size + test gates run on every PR.
Everything runs locally. The extension only goes online to check for updates
(you can turn this off in Settings), when you open the Community page, or when
you choose to download the optional tag list for autocomplete. Details are in
[docs/network-access.md](docs/network-access.md).
## Contributing
PRs welcome. See [CONTRIBUTING.md](./.github/CONTRIBUTING.md) for the dev loop (`pnpm install` → `pnpm dev` for the extension watcher, `pnpm test` for vitest, `pytest` for the engine, `ruff check .` for lint).
PRs are welcome. [CONTRIBUTING.md](.github/CONTRIBUTING.md) explains the dev setup.
## License
[GPL-3.0-or-later](./LICENSE). Free to use, modify, fork. If you ship modifications, ship the source too.
[GPL-3.0-or-later](LICENSE)
+69 -1
View File
@@ -4,7 +4,14 @@ import os
import sys
# Tells ComfyUI to serve files from this folder under
# /extensions/<package-name>/ and auto-load any `*.js` it finds.
# /extensions/<package-name>/ and auto-load any `*.js` it finds. Only the
# entry `main.js` uses that extension; lazy chunks are `.mjs` so ComfyUI does
# not import them all up front (vite.config.mts).
#
# "any *.js it finds" is load-bearing: ComfyUI fetches EVERY file it lists
# here on page load, so a chunk left behind by an older version is not merely
# dead weight on disk — it is a request and a parse on every visit. Hence the
# prune below.
WEB_DIRECTORY = "./js"
# Expose engine/ and wp_nodes/ as top-level packages so internal absolute imports
@@ -22,11 +29,70 @@ if _PKG_DIR not in sys.path:
# ruff: noqa: E402 — imports must follow the sys.path shim above
from comfy_api.latest import ComfyExtension
# Remove chunks left by previous versions BEFORE ComfyUI scans WEB_DIRECTORY.
# Manager updates extract over the installed folder without deleting what the
# new version dropped, and our filenames are content-hashed, so every update
# leaves the previous build behind. Self-healing on the next start; see
# `wp_api/web_assets.py` for the measurements that motivated it.
#
# Wrapped because nothing here is worth failing a plugin load over — the
# fallback is the disk usage that already exists.
try:
from pathlib import Path as _Path
from wp_api.web_assets import prune_package_assets as _prune_package_assets
_prune_package_assets(_Path(_PKG_DIR))
except Exception: # noqa: BLE001 - never crash ComfyUI over housekeeping
import logging as _logging
_logging.getLogger(__name__).exception(
"wildcard-pipeline: stale-asset prune failed",
)
# Move our files into `<ComfyUI>/user/wildcard-pipeline/` if a previous version
# left them loose in the user directory.
#
# Placement is load-bearing: this must run BEFORE anything opens the database.
# SQLite keeps `-wal` and `-shm` sidecars beside the main file, and moving that
# set out from under an open connection corrupts it. Node imports below can
# reach the DB, so the migration goes above them.
#
# Wrapped for the same reason as the prune above: the fallback is "keep using
# the old location", which is strictly better than failing to load.
try:
from engine.db.relocate import migrate_user_data as _migrate_user_data
_report = _migrate_user_data()
if _report.did_anything:
import logging as _logging
_log = _logging.getLogger(__name__)
if _report.moved:
_log.info(
"wildcard-pipeline: moved into user/wildcard-pipeline/: %s",
", ".join(_report.moved),
)
# Skips and failures are warnings: in both cases a file the user cares
# about is not where the extension will now look for it.
for _note in _report.skipped:
_log.warning("wildcard-pipeline: left in place — %s", _note)
for _note in _report.failed:
_log.warning("wildcard-pipeline: could not move %s", _note)
except Exception: # noqa: BLE001 - never crash ComfyUI over housekeeping
import logging as _logging
_logging.getLogger(__name__).exception(
"wildcard-pipeline: user-data relocation failed",
)
from wp_nodes.assembler_node import WPPromptAssembler
from wp_nodes.context_loop import WPContextLoop
from wp_nodes.context_node import WPContext
from wp_nodes.debug_node import WPDebug
from wp_nodes.image_filter_node import WPImageFilter
from wp_nodes.injector_node import WPContextInjector
from wp_nodes.model_info_node import WPModelInfo
from wp_nodes.prompt_cleaner import WPPromptCleaner
from wp_nodes.seed_list import WPSeedList
from wp_nodes.var_to_bool import WPVarToBool
@@ -44,6 +110,8 @@ class WildcardPipelineExtension(ComfyExtension):
WPPromptAssembler,
WPDebug,
WPContextInjector,
WPModelInfo,
WPImageFilter,
WPPromptCleaner,
WPSeedList,
WPVarToInt,
+14
View File
@@ -40,3 +40,17 @@ def wp_db(tmp_path):
yield conn
finally:
conn.close()
@pytest.fixture(autouse=True)
def _isolate_wp_settings(tmp_path, monkeypatch):
"""Keep every test off the real settings file and the backup scheduler.
`PipelineEngine.run` reads `engine/prefs.py` on every run and `migrate`
consults it before upgrading a DB, so without this a test could read — or
a Settings API test write — the developer's real `settings.json`.
`register_routes` starts the daily backup thread; tests that want it call
`engine.db.backups.start_daily_scheduler` directly.
"""
monkeypatch.setenv("WP_SETTINGS_PATH", str(tmp_path / "wp-settings.json"))
monkeypatch.setattr("wp_api._start_backups", lambda: None)
+1
View File
@@ -17,6 +17,7 @@ Lifts arbitrary ComfyUI outputs into named `$variable` bindings on the PipelineC
2. Each connected slot adds a row. Type the variable name (e.g. `style`).
3. Optionally tick **internal** to hide it from the assembled prompt while keeping it readable by Combine / Derivation rules.
4. Optionally write a template (`"I love $input_0"`) — the slot's raw value substitutes `$<slot_name>` before being written to ctx.
5. Optionally write a **Negative** (row Edit panel, under Template) with the same `$slot` grammar. It replaces the variable's negative words and reaches only Assemblers that render the variable. Template rows take one too.
## Tips
+11
View File
@@ -12,6 +12,7 @@ Optional power-user node that loops a WP_Context chain N times in a single workf
| `override_seed` | switch | When OFF (default): `seed` is ignored. Downstream WP_Context widget seeds drive each node; loop iteration only adds XOR variation. When ON: `seed` becomes the base; derived N times via `strategy`; replaces downstream widget seeds. |
| `iteration var` | text | Stamps `$<name>` (0..N-1) and `$<name>_total` (N) into each iteration's context. Default `iteration`. |
| `bypass` | switch | Skip the loop. Behaves as `count=1`. |
| Sweep combinations | menu | Run every combination of chosen wildcard options instead of `count` random rolls. See below. |
## Outputs
@@ -25,6 +26,16 @@ Each iteration runs the downstream chain with a different seed. Locked modules (
Concrete example: `count=3`, `override_seed=ON`, `seed=42`, `strategy=sequential` → downstream WP_Context chain runs three times with chain seeds 42, 43, 44. PromptAssembler emits three prompts; KSampler renders three batches; SaveImage writes three files.
## Sweep combinations
Open **Sweep combinations** on the node and switch it on. Pick wildcards from the WP_Context nodes after the loop and tick which of their options to include; one Generate then runs every combination, one frame each. Sweeping `$hair` (red, blonde, black) against `$mood` (calm, joyful, brooding) gives 9 prompts in grid order: the first wildcard changes slowest, the last fastest. Above each wildcard's options, **all** ticks every option, **none** clears them so you can tick just the few you want, and **enabled only** goes back to the wildcard's enabled options.
- **Limit** (default 64, max 999) caps the frames; extra combinations are skipped in order.
- **Hold other picks** (default on) keeps every module you didn't sweep on the same pick across frames, so only the swept wildcards change.
- The menu lists every frame before you run, the button shows the frame count, and `count` follows it.
- Seed locks and bypassed frames follow their combination when you change the sweep (a lock on red + calm stays on red + calm), and go away when that combination no longer runs.
- Each frame pins the swept wildcards like a hand-pinned option: tag axes still roll and constraints they drive still apply downstream.
## Tips
- The `seed` field is a stock ComfyUI INT widget with `control_after_generate` — same randomize / fixed / increment buttons KSampler exposes. Right-click → "Convert widget to input" to swap it for an rgthree Seed wire when you want its UI affordances.
+3 -3
View File
@@ -11,10 +11,10 @@ Inspect every variable flowing through the WP chain at this point. Terminal node
1. Drop it anywhere in the chain.
2. Run the workflow.
3. The body shows tabs:
- **Snapshot** — every bound variable + value.
- **Trace** — per-module resolution history.
- **Picks** — what each wildcard rolled.
- **Variables** — every bound variable + value and the step that set it. A variable's negative words sit under it in red, naming their module when it isn't the variable's own; the tab bar counts variables with negatives.
- **Trace** — every step in run order, grouped by Context node; open one for why it did what it did. An "Add to negative" derivation action reads `negative: …`.
- **Warnings** — unknown vars, constraints that never fired, etc.
- **Raw** — the snapshot JSON (including the negatives table).
Drag the bottom-right corner to resize.
+57
View File
@@ -0,0 +1,57 @@
# WP Image Filter
Pauses the run so you can pick which images go on. Everything that belongs to an image (its latent, masks, conditioning, prompt text and Context) is filtered with it, so the node can sit between the first KSampler and the upscaler and the upscaler only gets the picked images with their own prompts.
## Inputs and outputs
Every input has a matching output. Connect only what you need.
- **images**: the images to pick from. Required; this is what the picker shows.
- **latent**, **masks**: sliced to the picked images. A mask you paint in the picker replaces that image's mask.
- **positive**, **negative**: CONDITIONING. A batched conditioning is sliced with the images; a single one is kept for each frame that keeps any image.
- **positive_text**, **negative_text**: the prompt strings, kept or dropped with their frame.
- **context**: the WP Context of each frame, kept or dropped with it.
- **clip** (input only, optional): re-encodes a prompt you edit in the picker, so the positive/negative conditioning follows the edit. Without it an edit changes only the text outputs.
- **extra_1**, **extra_2**: any type. The output takes the type of what you connect. Batched values are sliced; anything else follows its frame.
- **picks** (output only): what was kept, as `frame:image` (1-based), for example `1:2, 3:1`.
A Context Loop (or any list) makes one **frame** per iteration. Lists line up by frame; a shorter list repeats its last item.
## The picker
- Click an image to pick it. Ctrl+A picks everything; a frame with several images has an **all** button that picks the frame.
- Click the frame around an image (its border or its label) to zoom in, where you can compare, edit the prompt and paint a mask. **Zoom & refine** in the header, or **Space**, opens and leaves the zoom too (Space zooms the image under the mouse). In the zoom, ←/→ move and ↑ picks. The picker remembers: leave it zoomed and the next one opens zoomed.
- **I** or **Details** (in the zoom) adds a Details panel for power users: the frame's variable values (swept ones first, in their grid colour; **Copy** copies them), its seed, the other images of the frame with **Pick whole frame**, and the positive prompt with each value marked where it landed (click it to edit). Everything else in the zoom still works, and the picker remembers whether Details was on.
- **C** (in the zoom) pins the image; step to another and a slider compares the two. **C** again stops.
- **Fit** (or **F**) in the header makes the images as big as the picker allows, so a handful of images fill the screen. The picker remembers it.
- **Enter** or **Keep N picked** sends the picks on. **Keep all** sends everything. **Stop branch** stops the nodes after this one; the rest of the run finishes normally.
- A short chime plays when the picker opens, and the tab title starts with **● Pick images** while something waits. Turn the chime off under **Settings → Wildcard Pipeline → Runtime behavior → Image Filter sound**. Turn on **Image Filter desktop notification** there to also get a system notification while the tab is in the background.
- **Escape** or the − button tucks the picker away. A "waiting" pill stays at the bottom of the screen and reopens it with your picks still there.
## Labels, sweep grid and Send to Loop
- With a Context Loop, each frame is labelled with its number and the options its sweep pinned, for example `#3 · red · pencil`. Hover a label for the frame's seed and prompt.
- A sweep of two or more wildcards opens as a **grid**. The bar above it picks which wildcard goes on **Rows** and which on **Columns** (⇄ swaps them); any others **Split by**, one small grid per value. Click a row, column or group name to pick all of it. **Frames** in the header switches back to the list.
- **Send to Loop** writes your picks back to the loop: picked frames get their seeds locked (when the loop overrides seeds) and every other frame is bypassed, so the next run repeats only what you kept.
## Refine before it goes on
In the zoom, the **Refine** panel edits the frame's prompts and paints a mask for the upscaler. Tiles show ✎ for an edited prompt and ◐ for a painted mask.
- An edited prompt replaces the text output. With a CLIP wired into **clip**, the conditioning is re-encoded to match; without one it stays as it was, and the panel says so.
- With **Same shape** a frame's picks go on together, so a prompt edit covers the whole frame. With **One per image** it covers just that image.
- **M** or **Paint** opens the mask painter: brush, eraser, size, invert and clear. The mask goes to the **masks** output, resized to the image. Images you don't paint keep the incoming mask, or an empty one when nothing is wired in.
![How the picks carry over](images/how-picks-carry-over.webp)
## Settings on the node
- **Mode**: **Pause & pick** asks every run. **Reuse last picks** keeps the same picks without asking, and asks again when they no longer fit (for example the batch size changed). **Pass all** lets everything through.
- **Send picks as**: **Same shape** keeps each frame's picks together as one batch. **One per image** sends every pick as its own item, with its own copy of the frame's prompt, conditioning and Context.
- **Timeout**: seconds to wait. 0 waits until you answer. After a timeout it keeps all, keeps the first image, or stops the branch.
## Tips
- The **LAST RUN** strip shows what the last run kept.
- Cancelling the run while the picker is open closes it.
- Use the **picks** output in a filename or a note to remember which images you picked.
+37
View File
@@ -0,0 +1,37 @@
# WP Model Info
Names the loaded checkpoint so your rules can branch on it. Writes three variables into the Context:
- `$model_family`: the architecture, read from the model itself (`sd15`, `sdxl`, `flux`, `sd3`, …).
- `$model_variant`: the fine-tune line, read from the file name by the variant rules (`pony`, `illustrious`, `noobai`, `animagine`, or empty when nothing matches).
- `$model_name`: the file name without folders or extension.
Pony and Illustrious are both SDXL underneath, so only the file name can tell them apart.
## Inputs
- **upstream** *(optional)*: an existing Context to extend. Only nodes **after** Model Info see its variables, so put it first in the chain.
- **model** *(optional)*: the MODEL from your checkpoint loader. Gives the family, and the node follows the wire back to the loader to read its file name (through LoRA loaders and other model patches).
## The widget
- **Detected** shows the three values and where each comes from. The variant and name update as soon as you change the loader's checkpoint; the family needs the loaded model, so it shows after the first run.
- The **globe** on a row decides whether the Assembler can use that variable. All three start internal: later rules read them, but the Assembler leaves them out of the prompt and its variable chips. Click the globe to use one in the Assembler, for example `$model_variant` in a file-name prefix.
- Click the **pin** on a row to use your own value instead, for example a file name when no model is wired, or a variant to try your Pony rules without switching checkpoint. Click it again to go back to detection.
- **Variant rules** are `variant` + `pattern` rows. The first pattern found in the file name sets `$model_variant`, and its row lights up. Case-insensitive, `|` separates alternatives. A broken row is outlined in red and skipped (WP Debug says why). **Reset** puts the shipped rules back.
## Output
- **context**: the upstream Context plus the three variables. To use a value outside the pipeline, turn its globe off and put it in an Assembler template.
## How to use
1. Wire **Load Checkpoint → MODEL** into **model**, and Model Info's **context** into your first WP Context.
2. Branch on `$model_variant` in a derivation. The derivation editor's **LoRA by model** button adds a ready-made rule that sets `$loras` per variant; put `$loras` in your Assembler template.
3. To drive a **constraint** from the model, make a wildcard (say "Model") with options `pony` and `illustrious`, and in its node settings set **Match variable** to `model_variant`. It then picks the option equal to the detected variant, and any constraint can use it as its source.
## Tips
- If a node that loads LoRAs from the prompt sits on the model, take MODEL from **before** it, or the graph loops.
- A wildcard set to **Match variable** uses its fallback option when no option matches; without one it rolls as usual and WP Debug says why.
- WP Debug's trace shows each value and where it came from (model, loader, rules or pinned).
+12 -1
View File
@@ -6,10 +6,21 @@ Fill-in-the-blank prompt template. Anywhere you write `$variable`, the assembler
- **context** — `PIPELINE_CONTEXT` from any WP node.
- **template** — your prompt text. Use `$varname` to reference bound values.
- **negative_template** — optional negative template (collapsible section under the preview). `$negatives` marks where the collected negative words go; it reads `$vars` too.
## Output
## Outputs
- **prompt** — the resolved STRING. Feed into CLIP encoders, WP Prompt Cleaner, etc.
- **negative** — the negative words of every variable the template rendered, filled into the negative template. Feed into your negative CLIP encoder.
## Negative output
Wildcard options, fixed values, combines, derivation "Add to negative" actions and Injector rows can carry negative words for the variable they set. The negative output only takes the words of variables this template actually rendered, so a detailer Assembler that renders just `$face` gets only the face words. Internal variables add nothing.
- `lowres, bad anatomy, $negatives` puts the collected words at the slot.
- An empty negative template outputs just the collected words.
- A negative template without `$negatives` gets them appended at the end.
- Repeated tags are dropped (case-insensitive, `(blurry:1.2)` counts as `blurry`), including tags already in the negative template.
## How to use
+20 -4
View File
@@ -1,14 +1,20 @@
# WP Prompt Cleaner
Drops duplicate tags, strips orphan punctuation, filters blocklisted words. Operates on the prompt string alone — no context input needed.
Drops duplicate tags, strips orphan punctuation, removes empty `()` groups, merges nested weights, tidies LoRA tags and filters blocklisted words. Operates on the prompt string alone — no context input needed.
## Inputs
- **prompt** — STRING from any source (typically a WP Prompt Assembler).
- **negative** — optional STRING (wire-only), e.g. the Assembler's negative output.
## Output
## Outputs
- **prompt** — the cleaned STRING.
- **negative** — the cleaned negative (empty when the negative input is unwired).
## Cleaning the negative
The RULES list has a second checkbox column for the negative. Its defaults match the prompt column's (gentle: whitespace only), except fuzzy dedupe and blocklist off, since near-duplicates in a negative are usually deliberate and blocklisted words often belong there. **Drop negative tags also in prompt** (off by default) removes a negative tag the prompt also asks for; either way the run stats name the overlap.
## How to use
@@ -27,11 +33,21 @@ Drops duplicate tags, strips orphan punctuation, filters blocklisted words. Oper
| Preset | Rules |
|---|---|
| gentle | whitespace |
| balanced | whitespace, punctuation, tag dedupe |
| aggressive | whitespace, punctuation, tag dedupe, fuzzy dedupe |
| balanced | empty groups, LoRA spacing, whitespace, punctuation, tag dedupe |
| aggressive | empty groups, merge weights, LoRA spacing, whitespace, punctuation, tag dedupe, fuzzy dedupe |
Blocklist auto-enables when its entries are non-empty.
## Prompt syntax rules
These read the prompt the way ComfyUI does, and work in both modes.
- **empty groups** — removes brackets with nothing inside: `()`, `[]`, `( , )`, `(:1.2)`, and groups left empty once those go. `red (), dress` → `red, dress`. The usual source is a weighted ref that resolved to nothing: `(@{hat}:1.2)` → `(:1.2)`.
- **merge weights** — folds stacked weights into one group by multiplying them, as ComfyUI does (a bare `( )` counts as 1.1): `((a:1.1):1.2)` → `(a:1.32)`, `(((a)))` → `(a:1.33)`. A weight of 1 is unwrapped: `(a:1.0)` → `a`. Only merges when the inner group is everything inside the outer one (`((a:1.1), b:1.2)` stays), and rounds to 2 decimals. `[ ]` is not a weight in ComfyUI, so it's never merged.
- **LoRA spacing** — `< lora : x : 0.8 >` → `<lora:x:0.8>`, `girl<lora:x:1>smile` → `girl <lora:x:1> smile`. Also covers lyco, locon, hypernet and `<embedding:…>`. Spaces inside a file name stay.
Escaped `\(` `\)` are literal and left alone, LoRA and embedding names are never edited, and if the brackets don't balance the bracket rules leave the prompt exactly as written.
## Blocklist
Click **Blocklist…** to open the editor. Two modes:
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+41
View File
@@ -0,0 +1,41 @@
# Network access
Wildcard Pipeline runs offline. Generating prompts, the canvas nodes and the
library manager never touch the network. These are the only outbound requests
it makes:
| What | When | Where to |
| --- | --- | --- |
| Update check | When the manager opens (turn off under Settings → Updates), or when you click **Check now** | GitHub releases API |
| Community tab | Only when you open the **Community** page in the manager | `wp.dumiflex.dev` |
| Booru tag list for value autocomplete | Only when you click **Download** under Settings → Tag autocomplete | `github.com` → `objects.githubusercontent.com`, our own release asset |
Delete the downloaded tag file and the autocomplete feature simply turns off.
## How the tag-list download is locked down
ComfyUI serves its API without authentication, so the download endpoint is
built with nothing a caller can steer:
- **The URL is a constant.** There is no URL parameter (the request body is
ignored), so it cannot be pointed at your internal network or a cloud
metadata address.
- **Redirects are checked at every hop** against a fixed host list, because
GitHub serves release assets via a CDN. A redirect anywhere else is refused
mid-chain.
- **The destination is computed server-side** from ComfyUI's user directory
plus a fixed filename. No caller input reaches the path, and a symlink
sitting at that path is refused rather than followed.
- **The response is size-capped while streaming**, so a hostile or corrupted
reply cannot fill your disk. `Content-Length` is checked but never trusted
alone.
- **One download at a time**, so the endpoint cannot be used to start many
large fetches at once.
- **The file is parsed before it replaces anything.** A reply that is not a
readable tag list leaves your existing one untouched, and the final move is
atomic.
The reasoning is kept next to the code in
[`wp_api/_tag_download.py`](../wp_api/_tag_download.py), and each restriction
has a test in
[`tests/wp_api/test_tag_download.py`](../tests/wp_api/test_tag_download.py).
View File
+113 -81
View File
@@ -41,6 +41,33 @@ from engine.syntax.subcat_filter import ParseError as _ParseError
from engine.syntax.subcat_filter import parse as _parse_subcat
from engine.syntax.subcat_filter import reads_as as _reads_as
def derivation_actions(payload: Any) -> list[tuple[str, dict[str, Any]]]:
"""Every action dict of a derivation payload with its path: each branch's
``action`` and ``extra_actions`` (THEN ... AND ...), the ``else`` clause's
too, and the legacy ``actions`` list older rows may still carry. The
dicts are the payload's own, so a fixer can rewrite them in place."""
out: list[tuple[str, dict[str, Any]]] = []
rules = payload.get("rules") if isinstance(payload, dict) else None
for ri, rule in enumerate(rules if isinstance(rules, list) else []):
if not isinstance(rule, dict):
continue
clauses: list[tuple[str, Any]] = [
(f"rules[{ri}].branches[{bi}]", b)
for bi, b in enumerate(rule.get("branches") or [])
]
clauses.append((f"rules[{ri}].else", rule.get("else")))
for where, clause in clauses:
if not isinstance(clause, dict):
continue
if isinstance(clause.get("action"), dict):
out.append((f"{where}.action", clause["action"]))
for key in ("extra_actions", "actions"):
for ai, action in enumerate(clause.get(key) or []):
if isinstance(action, dict):
out.append((f"{where}.{key}[{ai}]", action))
return out
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
@@ -327,26 +354,24 @@ def fix_wildcard_delete(
changed = False
if t == "wildcard":
# `negative` (send-to-negative) holds refs with the same grammar.
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _strip_whole_ref_in_string(v, wildcard_id)
if new_v != v:
opt["value"] = new_v
changed = True
for field in ("value", "negative"):
v = opt.get(field)
if isinstance(v, str):
new_v = _strip_whole_ref_in_string(v, wildcard_id)
if new_v != v:
opt[field] = new_v
changed = True
elif t == "derivation":
for rule in new_payload.get("rules") or []:
for branch in rule.get("branches") or []:
for action in branch.get("actions") or []:
if not isinstance(action, dict):
continue
for k, v in list(action.items()):
if isinstance(v, str):
new_v = _strip_whole_ref_in_string(v, wildcard_id)
if new_v != v:
action[k] = new_v
changed = True
for _path, action in derivation_actions(new_payload):
for k, v in list(action.items()):
if isinstance(v, str):
new_v = _strip_whole_ref_in_string(v, wildcard_id)
if new_v != v:
action[k] = new_v
changed = True
if changed:
touched.append(_deepcopy_row(m))
@@ -417,13 +442,15 @@ def fix_subcat_delete(
elif t == "wildcard" and m["id"] != wildcard_id:
# Strip @{wildcard_id:subcat_name} text refs from option values
# `negative` (send-to-negative) holds refs with the same grammar.
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _strip_subcat_ref_in_string(v, wildcard_id, subcat_name)
if new_v != v:
opt["value"] = new_v
changed = True
for field in ("value", "negative"):
v = opt.get(field)
if isinstance(v, str):
new_v = _strip_subcat_ref_in_string(v, wildcard_id, subcat_name)
if new_v != v:
opt[field] = new_v
changed = True
if changed:
touched.append(_deepcopy_row(m))
@@ -496,13 +523,15 @@ def fix_subcat_rename(
elif t == "wildcard" and m["id"] != wildcard_id:
# Rewrite @{wildcard_id:old_name} → @{wildcard_id:new_name}
# `negative` (send-to-negative) holds refs with the same grammar.
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _rewrite_subcat_ref_in_string(v, wildcard_id, old_name, new_name)
if new_v != v:
opt["value"] = new_v
changed = True
for field in ("value", "negative"):
v = opt.get(field)
if isinstance(v, str):
new_v = _rewrite_subcat_ref_in_string(v, wildcard_id, old_name, new_name)
if new_v != v:
opt[field] = new_v
changed = True
if changed:
touched.append(_deepcopy_row(m))
@@ -560,26 +589,24 @@ def fix_wildcard_rename_name(
changed = False
if t == "wildcard":
# `negative` (send-to-negative) holds refs with the same grammar.
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _rewrite_ref_name_in_string(v, wildcard_id, new_name)
if new_v != v:
opt["value"] = new_v
changed = True
for field in ("value", "negative"):
v = opt.get(field)
if isinstance(v, str):
new_v = _rewrite_ref_name_in_string(v, wildcard_id, new_name)
if new_v != v:
opt[field] = new_v
changed = True
elif t == "derivation":
for rule in new_payload.get("rules") or []:
for branch in rule.get("branches") or []:
for action in branch.get("actions") or []:
if not isinstance(action, dict):
continue
for k, v in list(action.items()):
if isinstance(v, str):
new_v = _rewrite_ref_name_in_string(v, wildcard_id, new_name)
if new_v != v:
action[k] = new_v
changed = True
for _path, action in derivation_actions(new_payload):
for k, v in list(action.items()):
if isinstance(v, str):
new_v = _rewrite_ref_name_in_string(v, wildcard_id, new_name)
if new_v != v:
action[k] = new_v
changed = True
if changed:
touched.append(_deepcopy_row(m))
@@ -668,46 +695,51 @@ def fix_combine_output_var_rename(
new_payload["output_var"] = new_name
changed = True
# Also rewrite $old in its own template in case it self-references
tpl = new_payload.get("template", "")
if isinstance(tpl, str):
new_tpl = _rewrite_var_in_string(tpl, old_name, new_name)
if new_tpl != tpl:
new_payload["template"] = new_tpl
changed = True
elif t == "wildcard":
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _rewrite_var_in_string(v, old_name, new_name)
if new_v != v:
opt["value"] = new_v
# The combine's own `negative` reads $vars like its template.
for field in ("template", "negative"):
tpl = new_payload.get(field, "")
if isinstance(tpl, str):
new_tpl = _rewrite_var_in_string(tpl, old_name, new_name)
if new_tpl != tpl:
new_payload[field] = new_tpl
changed = True
elif t == "wildcard":
# `negative` (send-to-negative) holds refs with the same grammar.
for opt in new_payload.get("options") or []:
for field in ("value", "negative"):
v = opt.get(field)
if isinstance(v, str):
new_v = _rewrite_var_in_string(v, old_name, new_name)
if new_v != v:
opt[field] = new_v
changed = True
elif t == "derivation":
for rule in new_payload.get("rules") or []:
for branch in rule.get("branches") or []:
for action in branch.get("actions") or []:
if not isinstance(action, dict):
continue
for k in list(action.keys()):
v = action[k]
if k == "set_var" and v == old_name:
action[k] = new_name
changed = True
elif isinstance(v, str):
new_v = _rewrite_var_in_string(v, old_name, new_name)
if new_v != v:
action[k] = new_v
changed = True
for _path, action in derivation_actions(new_payload):
for k in list(action.keys()):
v = action[k]
# `target_var` names the variable bare (no `$`); the
# legacy `set_var` did too.
if k in ("set_var", "target_var") and isinstance(v, str) \
and v.lstrip("$") == old_name:
action[k] = new_name
changed = True
elif isinstance(v, str):
new_v = _rewrite_var_in_string(v, old_name, new_name)
if new_v != v:
action[k] = new_v
changed = True
elif t == "combine" and m["id"] != combine_id:
tpl = new_payload.get("template", "")
if isinstance(tpl, str):
new_tpl = _rewrite_var_in_string(tpl, old_name, new_name)
if new_tpl != tpl:
new_payload["template"] = new_tpl
changed = True
# The combine's own `negative` reads $vars like its template.
for field in ("template", "negative"):
tpl = new_payload.get(field, "")
if isinstance(tpl, str):
new_tpl = _rewrite_var_in_string(tpl, old_name, new_name)
if new_tpl != tpl:
new_payload[field] = new_tpl
changed = True
if changed:
touched.append(_deepcopy_row(m))
+42 -62
View File
@@ -17,7 +17,7 @@ import re
import sqlite3
from typing import Any
from engine.cascade.fixers import collect_tags
from engine.cascade.fixers import collect_tags, derivation_actions
from engine.db.repositories import ModuleRepository
# 4-segment ref: ``@{8hex [#name] [:expr] [!null]}`` — uuid + optional
@@ -67,38 +67,28 @@ def _scan_wildcard_delete(conn: sqlite3.Connection, wildcard_id: str) -> list[di
if t == "wildcard":
opts = p.get("options") or []
for idx, opt in enumerate(opts):
v = opt.get("value")
if not isinstance(v, str):
continue
if any(m.group(1) == wildcard_id for m in _REF_REGEX.finditer(v)):
out.append(_ref_entry("wildcard", m, f"options[{idx}].value"))
hit = next((
f for f in ("value", "negative")
if isinstance(opt.get(f), str) and any(
r.group(1) == wildcard_id
for r in _REF_REGEX.finditer(opt[f])
)
), None)
if hit:
out.append(_ref_entry("wildcard", m, f"options[{idx}].{hit}"))
break
continue
if t == "derivation":
found = False
rules = p.get("rules") or []
for ri, rule in enumerate(rules):
if found:
for path, action in derivation_actions(p):
if any(
isinstance(v, str) and any(
r.group(1) == wildcard_id for r in _REF_REGEX.finditer(v)
)
for v in action.values()
):
out.append(_ref_entry("derivation", m, path))
break
for bi, branch in enumerate(rule.get("branches") or []):
if found:
break
for ai, action in enumerate(branch.get("actions") or []):
if not isinstance(action, dict):
continue
for v in action.values():
if isinstance(v, str) and any(
m.group(1) == wildcard_id for m in _REF_REGEX.finditer(v)
):
out.append(_ref_entry(
"derivation", m,
f"rules[{ri}].branches[{bi}].actions[{ai}]",
))
found = True
break
if found:
break
continue
# Bundles intentionally excluded from the wildcard-delete impact
@@ -150,9 +140,10 @@ def _scan_subcat(
if m["type"] == "wildcard" and m["id"] != wildcard_id:
opts = (m.get("payload") or {}).get("options") or []
for idx, opt in enumerate(opts):
v = opt.get("value")
if not isinstance(v, str):
continue
v = " ".join(
opt[f] for f in ("value", "negative")
if isinstance(opt.get(f), str)
)
# SP1: `sub` is the raw `:expr` segment (the 4-segment regex
# already split `!null` into the null group). A boolean filter
# like `warm or cold` references multiple tags, so parse it and
@@ -189,41 +180,30 @@ def _scan_combine_output_var(
continue
if t == "derivation":
found = False
for ri, rule in enumerate(p.get("rules") or []):
if found:
break
for bi, branch in enumerate(rule.get("branches") or []):
if found:
for path, action in derivation_actions(p):
hit = None
for k, v in action.items():
# The variable read in a string value (`$name`,
# `$name.neg`, ...) or named as the action's target.
if isinstance(v, str) and var_name in _VAR_REGEX.findall(v):
hit = f"{path}.{k}"
break
for ai, action in enumerate(branch.get("actions") or []):
if not isinstance(action, dict):
continue
for k, v in action.items():
# Match as variable ref in a string value
if isinstance(v, str) and var_name in _VAR_REGEX.findall(v):
out.append(_ref_entry(
"derivation", m,
f"rules[{ri}].branches[{bi}].actions[{ai}].{k}",
))
found = True
break
# Or match as the explicit set_var target
if k == "set_var" and v == var_name:
out.append(_ref_entry(
"derivation", m,
f"rules[{ri}].branches[{bi}].actions[{ai}].set_var",
))
found = True
break
if found:
break
if k in ("set_var", "target_var") and isinstance(v, str) \
and v.lstrip("$") == var_name:
hit = f"{path}.{k}"
break
if hit:
out.append(_ref_entry("derivation", m, hit))
break
continue
if t == "combine":
tpl = p.get("template", "")
if isinstance(tpl, str) and var_name in _VAR_REGEX.findall(tpl):
out.append(_ref_entry("combine", m, "payload.template"))
# A combine's own `negative` reads $vars like its template.
for field in ("template", "negative"):
tpl = p.get(field, "")
if isinstance(tpl, str) and var_name in _VAR_REGEX.findall(tpl):
out.append(_ref_entry("combine", m, f"payload.{field}"))
break
continue
return out
+61
View File
@@ -0,0 +1,61 @@
"""Prompt-syntax atoms the cleaner must not rewrite.
The cleaner's rules treat a prompt as prose or as a comma-separated tag list.
That is the right model for tags, and the wrong one for the handful of tokens
that are *addresses* rather than words — `embedding:name` and `<lora:name:1.0>`
resolve to a file on disk, so a single edited character silently changes what
the sampler loads, or loads nothing at all.
Two rules were provably destructive (P3 #31, pinned by
`tests/engine/cleaner/test_embedding_roundtrip.py`):
* punctuation stripping — `_` and `-` are in the stripped set, so
`embedding:bad_prompt_` (a real published shape) became
`embedding:bad_prompt`. ComfyUI resolves a missing embedding to nothing,
so the negative prompt quietly stopped working, with no error.
* fuzzy dedupe — versioned names are near-identical strings and the
threshold is 0.9, so `embedding:badhandv4, embedding:badhandv5` lost the
second. Two different files, one silently dropped.
This widens the cleaner's remit from "text" to "knows a little prompt syntax",
which the findings called out as the cost of fixing it. The alternative was
leaving a rule that corrupts a file reference without saying so.
Deliberately narrow: only the well-known forms, and only whole tokens. Anything
not matched here keeps its existing treatment.
"""
from __future__ import annotations
import re
# `embedding:name`, optionally weighted / parenthesised by the caller. The name
# runs to the next separator: whitespace, comma, or a closing paren/angle.
_EMBEDDING_RE = re.compile(r"\bembedding:[^\s,)\]>]+", re.IGNORECASE)
# `<lora:name:1.0>` and the siblings that share its shape. Matched whole, so
# internal punctuation and decimal weights are untouchable.
_ANGLE_RE = re.compile(
r"<\s*(?:lora|lyco|locon|hypernet|embedding)\s*:[^>]*>",
re.IGNORECASE,
)
def contains_atom(text: str) -> bool:
"""True when `text` carries a file-reference token cleaning must preserve."""
if not text:
return False
return bool(_EMBEDDING_RE.search(text) or _ANGLE_RE.search(text))
def ends_with_atom(text: str) -> bool:
"""True when `text` ENDS in an atom, so trimming its trailing edge would
eat part of the reference. Used by the text-mode punctuation pass, which
trims the whole string rather than per-tag."""
if not text:
return False
stripped = text.rstrip()
for pattern in (_EMBEDDING_RE, _ANGLE_RE):
for match in pattern.finditer(stripped):
if match.end() == len(stripped):
return True
return False
+84 -3
View File
@@ -21,8 +21,19 @@ from engine.cleaner.types import RuleId, RunReport
INTENSITY_TO_RULES: dict[str, list[RuleId]] = {
"gentle": ["whitespace"],
"balanced": ["whitespace", "punctuation", "dedupe_exact"],
# Empty groups + LoRA spacing never change what a prompt means; merging
# weights rewrites how it reads, so it waits for aggressive.
"balanced": [
"empty_groups",
"lora_spacing",
"whitespace",
"punctuation",
"dedupe_exact",
],
"aggressive": [
"empty_groups",
"merge_weights",
"lora_spacing",
"whitespace",
"punctuation",
"dedupe_exact",
@@ -31,8 +42,36 @@ INTENSITY_TO_RULES: dict[str, list[RuleId]] = {
}
def _effective_rules(intensity: str, overrides: dict[str, bool]) -> set[RuleId]:
base: set[RuleId] = set(INTENSITY_TO_RULES.get(intensity, INTENSITY_TO_RULES["balanced"]))
#: Send-to-negative: the negative column's defaults per preset. Fuzzy dedupe
#: stays off (near-duplicates in a negative, "bad hand" / "bad hands", are
#: usually deliberate) and the blocklist never auto-enables (it keeps words
#: OUT of the prompt, and those are often the words a negative names).
INTENSITY_TO_NEG_RULES: dict[str, list[RuleId]] = {
"gentle": ["whitespace"],
"balanced": [
"empty_groups",
"lora_spacing",
"whitespace",
"punctuation",
"dedupe_exact",
],
"aggressive": [
"empty_groups",
"merge_weights",
"lora_spacing",
"whitespace",
"punctuation",
"dedupe_exact",
],
}
def _effective_rules(
intensity: str,
overrides: dict[str, bool],
table: dict[str, list[RuleId]] = INTENSITY_TO_RULES,
) -> set[RuleId]:
base: set[RuleId] = set(table.get(intensity, table["balanced"]))
for rule_id, enabled in overrides.items():
if enabled:
base.add(rule_id) # type: ignore[arg-type]
@@ -70,3 +109,45 @@ class PromptCleaner:
out = result["text"]
report[rule_id] = result["stats"]
return {"text": out, "report": report}
def run_negative(
self,
text: str,
config: dict[str, Any] | None = None,
prompt: str = "",
) -> dict[str, Any]:
"""Clean the negative with the negative column's rules.
`negative_rules_override` is the column's per-rule toggles; the
blocklist entries are shared with the prompt column. The report
always names tags the negative shares with `prompt` (the model is
told to draw them and not draw them); `drop_prompt_overlap` removes
them from the negative.
"""
cfg = config or {}
mode = cfg.get("mode", "tags")
intensity = cfg.get("intensity", "balanced")
overrides: dict[str, bool] = dict(cfg.get("negative_rules_override") or {})
active = _effective_rules(intensity, overrides, INTENSITY_TO_NEG_RULES)
out = text
report: dict[str, Any] = {}
for rule_id, fn in RULE_REGISTRY:
if rule_id not in active:
continue
result = fn(out, mode, cfg)
out = result["text"]
report[rule_id] = result["stats"]
from engine.negatives import split_tags, tag_key # noqa: PLC0415
prompt_keys = {tag_key(t) for t in split_tags(prompt)}
tags = split_tags(out)
overlap = [t for t in tags if tag_key(t) in prompt_keys]
if overlap:
report["prompt_overlap"] = {
"tags": overlap,
"dropped": bool(cfg.get("drop_prompt_overlap")),
}
if cfg.get("drop_prompt_overlap"):
out = ", ".join(t for t in tags if tag_key(t) not in prompt_keys)
return {"text": out, "report": report}
+7
View File
@@ -12,14 +12,21 @@ from collections.abc import Callable
from engine.cleaner.rules import (
blocklist,
dedupe,
lora,
punctuation,
syntax,
whitespace,
)
from engine.cleaner.types import RuleId, RuleResult
ApplyFn = Callable[[str, str, dict], RuleResult]
# The bracket and LoRA rules run first: the commas and spaces they leave
# behind are then tidied by whitespace / punctuation in the same run.
RULE_REGISTRY: list[tuple[RuleId, ApplyFn]] = [
("empty_groups", syntax.apply_empty_groups),
("merge_weights", syntax.apply_merge_weights),
("lora_spacing", lora.apply),
("whitespace", whitespace.apply),
("punctuation", punctuation.apply),
("dedupe_exact", dedupe.apply_exact),
+11
View File
@@ -8,6 +8,7 @@
"""
from __future__ import annotations
from engine.cleaner.atoms import contains_atom
from engine.cleaner.types import RuleResult
_FUZZY_THRESHOLD = 0.9
@@ -74,7 +75,17 @@ def apply_fuzzy(text: str, mode: str, config: dict) -> RuleResult:
for tag in tags:
key = tag.casefold()
is_dup = False
# Versioned file references are near-identical strings by design --
# `embedding:badhandv4` vs `...v5` scores well above the 0.9 threshold
# while naming two different files. Exact dedupe still collapses a
# genuinely repeated one; only the FUZZY pass is held back.
tag_is_atom = contains_atom(tag)
for kept_tag in kept:
if tag_is_atom or contains_atom(kept_tag):
if key == kept_tag.casefold():
is_dup = True
break
continue
if _ratio(key, kept_tag.casefold()) >= _FUZZY_THRESHOLD:
is_dup = True
break
+61
View File
@@ -0,0 +1,61 @@
"""LoRA tag spacing.
Tidies `<lora:name:0.8>` style tags (and the lyco / locon / hypernet /
embedding siblings that share the shape, see engine/cleaner/atoms.py):
* inside the tag, spaces at the edges of each `:` segment go
(`< lora : name : 0.8 >` -> `<lora:name:0.8>`). Spaces INSIDE a
segment stay, so a file name with spaces still resolves;
* outside, a tag jammed against a word gets one space on that side
(`girl<lora:x:1>smile` -> `girl <lora:x:1> smile`), and spaces
between a tag and a following comma go (`<lora:x:1> , hat` ->
`<lora:x:1>, hat`).
Reports `stats.tidied` = tags changed.
"""
from __future__ import annotations
import re
from engine.cleaner.types import RuleResult
_TAG_RE = re.compile(
r"<\s*(?P<kind>lora|lyco|locon|hypernet|embedding)\s*:(?P<rest>[^<>]*)>",
re.IGNORECASE,
)
# A tag needs a space from a neighbour unless that neighbour is whitespace,
# a separator or a bracket edge.
_NO_SPACE_BEFORE = set(" \t\n,([{")
_NO_SPACE_AFTER = set(" \t\n,)]}")
def apply(text: str, mode: str, config: dict) -> RuleResult:
tidied = 0
out: list[str] = []
pos = 0
for m in _TAG_RE.finditer(text):
start, end = m.start(), m.end()
before = text[pos:start]
segments = [s.strip() for s in m.group("rest").split(":")]
tag = f"<{m.group('kind')}:{':'.join(segments)}>"
changed = tag != m.group(0)
# Space on the left when the tag touches a word (or another tag).
prev = (out[-1][-1:] if not before and out else before[-1:]) or ""
if prev and prev not in _NO_SPACE_BEFORE:
before += " "
changed = True
out.append(before + tag)
pos = end
# Spaces between the tag and a following comma.
after = text[pos:]
stripped = after.lstrip(" \t")
if stripped.startswith(",") and stripped != after:
pos += len(after) - len(stripped)
changed = True
elif after and after[0] not in _NO_SPACE_AFTER and not _TAG_RE.match(after):
out.append(" ")
changed = True
if changed:
tidied += 1
out.append(text[pos:])
return {"text": "".join(out) if tidied else text, "stats": {"tidied": tidied}}
+18 -3
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import re
from engine.cleaner.atoms import contains_atom, ends_with_atom
from engine.cleaner.types import RuleResult
_PUNCT_CHARS = r".,:;!?\-_"
@@ -37,6 +38,13 @@ def apply(text: str, mode: str, config: dict) -> RuleResult:
if _PURE_PUNCT_RE.match(tag):
stripped += 1
continue
# `embedding:name` / `<lora:name:1.0>` are file references, and
# `_` and `-` are in the stripped set — trimming an edge renames
# the file. ComfyUI resolves a missing embedding to nothing, so
# the damage is silent. See engine/cleaner/atoms.py.
if contains_atom(tag):
kept.append(tag)
continue
edged, changed = _strip_edges(tag)
if changed:
stripped += 1
@@ -44,7 +52,14 @@ def apply(text: str, mode: str, config: dict) -> RuleResult:
kept.append(edged)
out = ", ".join(kept)
else:
out, changed = _strip_edges(text)
if changed:
stripped += 1
# Text mode trims the whole string's edges rather than per tag, so the
# only risk is a prompt that ENDS in an atom.
if ends_with_atom(text):
out = _LEADING_PUNCT_RE.sub("", text)
if out != text:
stripped += 1
else:
out, changed = _strip_edges(text)
if changed:
stripped += 1
return {"text": out, "stats": {"stripped": stripped}}
+243
View File
@@ -0,0 +1,243 @@
"""Bracket-aware rules: empty groups + nested weight merging.
Both rules read the prompt as ComfyUI's weight grammar sees it: `( )`
groups (each bare group is a 1.1 weight, `(x:w)` sets one, nested weights
multiply) and `[ ]` groups (not a weight in ComfyUI, but still emptied).
The scanner keeps three things as literal text it never looks inside:
* escapes — `\\(` and `\\)` are literal parens (`artist \\(style\\)`);
* angle tags — `<lora:name:0.8>` and siblings are file references;
* anything after a bracket that doesn't balance — see below.
If the brackets don't balance (a stray `(` or `)`), the rules return the
prompt exactly as written: guessing which paren the author meant could
re-weight half the prompt.
`empty_groups` drops `()`, `[]`, `( , )`, `(:1.2)` and groups left empty
once their own empty children are gone, then closes the seam the group
left (`red (), dress` -> `red, dress`), the way the resolver's join tidy
closes the seam an empty `@{ref}` leaves.
`merge_weights` folds a group whose whole content is another `( )` group
into one group with the product weight (`((a:1.1):1.2)` -> `(a:1.32)`,
`((a))` -> `(a:1.21)`), and unwraps a group whose weight is exactly 1
(`(a:1.0)` -> `a`). Weights are rounded to 2 decimals. A group with
anything beside the inner group (`((a:1.1), b:1.2)`) is left alone, since
merging would change what the outer weight applies to.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from engine.cleaner.types import RuleResult
_OPEN = {"(": ")", "[": "]"}
_CLOSE = {")", "]"}
# `:1.2` at the end of a group's content. ComfyUI splits on the LAST colon
# and only treats the tail as a weight when it parses as a number.
_WEIGHT_TAIL = re.compile(r"^(?P<body>.*?)\s*:\s*(?P<w>[-+]?(?:\d+\.?\d*|\.\d+))\s*$", re.DOTALL)
_EMPTY_TAIL = re.compile(r"^[\s,]*(?::\s*[-+]?(?:\d+\.?\d*|\.\d+)\s*)?[\s,]*$")
#: Placeholder for a removed group until its seam is closed. A control char
#: no prompt contains.
_GAP = "\x00"
_HWS = " \t"
@dataclass
class _Group:
open: str
children: list[str | _Group] = field(default_factory=list)
#: Built by a merge, so folding it again isn't a second merge.
merged: bool = False
def _parse(text: str) -> list[str | _Group] | None:
"""Split `text` into literal runs and bracket groups. None when the
brackets don't balance."""
root: list[str | _Group] = []
stack: list[_Group] = []
buf: list[str] = []
def flush() -> None:
if buf:
(stack[-1].children if stack else root).append("".join(buf))
buf.clear()
i, n = 0, len(text)
while i < n:
ch = text[i]
if ch == "\\" and i + 1 < n:
buf.append(text[i : i + 2])
i += 2
continue
if ch == "<":
end = text.find(">", i + 1)
nxt = text.find("<", i + 1)
if end != -1 and (nxt == -1 or nxt > end):
buf.append(text[i : end + 1])
i = end + 1
continue
if ch in _OPEN:
flush()
group = _Group(ch)
(stack[-1].children if stack else root).append(group)
stack.append(group)
elif ch in _CLOSE:
if not stack or _OPEN[stack[-1].open] != ch:
return None
flush()
stack.pop()
else:
buf.append(ch)
i += 1
if stack:
return None
flush()
return root
def _render(nodes: list[str | _Group]) -> str:
return "".join(n if isinstance(n, str) else _render_group(n) for n in nodes)
def _render_group(g: _Group) -> str:
return g.open + _render(g.children) + _OPEN[g.open]
# ---------------------------------------------------------------- empty groups
def _drop_empty(nodes: list[str | _Group], counter: list[int]) -> list[str | _Group]:
out: list[str | _Group] = []
for node in nodes:
if isinstance(node, str):
out.append(node)
continue
node.children = _drop_empty(node.children, counter)
inner = _render(node.children).replace(_GAP, "")
if _EMPTY_TAIL.match(inner):
counter[0] += 1
out.append(_GAP)
else:
out.append(node)
return out
def _close_gaps(text: str) -> str:
"""Close the seam each removed group left behind."""
while _GAP in text:
idx = text.index(_GAP)
left = text[:idx].rstrip(_HWS)
right = text[idx + 1 :]
# Runs of gaps (`() []`) close as one seam.
while right.lstrip(_HWS).startswith(_GAP):
right = right.lstrip(_HWS)[1:]
had_ws = text[:idx] != left or right[:1] in (" ", "\t")
right = right.lstrip(_HWS)
if not left:
if right.startswith(","):
right = right[1:].lstrip(_HWS)
text = right
continue
if not right:
text = left[:-1].rstrip(_HWS) if left.endswith(",") else left
continue
if left.endswith(",") and right.startswith(","):
right = right[1:].lstrip(_HWS)
text = left + (" " if right else "") + right
continue
if left.endswith(",") and right[0] in ")]":
text = left[:-1].rstrip(_HWS) + right
continue
if left[-1] in "([" and right.startswith(","):
text = left + right[1:].lstrip(_HWS)
continue
joiner = "" if right[0] in ",)]\n" or left[-1] in "([\n" else (" " if had_ws else "")
text = left + joiner + right
return text
def apply_empty_groups(text: str, mode: str, config: dict) -> RuleResult:
tree = _parse(text)
if tree is None:
return {"text": text, "stats": {"removed": 0}}
counter = [0]
tree = _drop_empty(tree, counter)
if not counter[0]:
return {"text": text, "stats": {"removed": 0}}
return {"text": _close_gaps(_render(tree)), "stats": {"removed": counter[0]}}
# --------------------------------------------------------------- merge weights
def _fmt(weight: float) -> str:
return f"{weight:.2f}".rstrip("0").rstrip(".")
def _split_weight(g: _Group) -> tuple[list[str | _Group], float, bool]:
"""(body nodes, weight, explicit) for a `( )` group. The weight tail can
only sit in the group's last literal run."""
last = g.children[-1] if g.children else None
if isinstance(last, str):
m = _WEIGHT_TAIL.match(last)
if m and (m.group("body").strip() or len(g.children) > 1):
body = [*g.children[:-1], m.group("body")] if m.group("body") else list(g.children[:-1])
return body, float(m.group("w")), True
return list(g.children), 1.1, False
def _only_group(body: list[str | _Group]) -> _Group | None:
"""The single `( )` group `body` consists of, ignoring whitespace."""
groups = [n for n in body if isinstance(n, _Group)]
if len(groups) != 1 or groups[0].open != "(":
return None
if any(isinstance(n, str) and n.strip() for n in body):
return None
return groups[0]
def _merge(nodes: list[str | _Group], counter: list[int]) -> list[str | _Group]:
out: list[str | _Group] = []
for node in nodes:
if isinstance(node, str):
out.append(node)
continue
node.children = _merge(node.children, counter)
if node.open != "(":
out.append(node)
continue
body, weight, explicit = _split_weight(node)
inner = _only_group(body)
changed = False
counted = inner is not None and inner.merged
while inner is not None:
inner_body, inner_w, _ = _split_weight(inner)
body, weight, explicit = inner_body, round(weight * inner_w, 6), True
changed = True
inner = _only_group(body)
if explicit and round(weight, 2) == 1:
counter[0] += 0 if counted else 1
out.extend(body)
continue
if changed:
counter[0] += 0 if counted else 1
out.append(_Group("(", [*body, f":{_fmt(weight)}"], merged=True))
continue
out.append(node)
return out
def apply_merge_weights(text: str, mode: str, config: dict) -> RuleResult:
tree = _parse(text)
if tree is None:
return {"text": text, "stats": {"merged": 0}}
counter = [0]
tree = _merge(tree, counter)
if not counter[0]:
return {"text": text, "stats": {"merged": 0}}
return {"text": _render(tree), "stats": {"merged": counter[0]}}
+3
View File
@@ -9,6 +9,9 @@ from __future__ import annotations
from typing import Any, Literal, TypedDict
RuleId = Literal[
"empty_groups",
"merge_weights",
"lora_spacing",
"whitespace",
"punctuation",
"dedupe_exact",
+26
View File
@@ -21,6 +21,32 @@ class ContextInternals(TypedDict, total=False):
Context = dict[str, Any]
#: Engine tables the text resolver reads by KEY rather than as variables.
#: `strip_internals` still drops them — its result is the variable surface, and
#: anything enumerating it (the assembler's variables panel, debug views) would
#: list `$__wp_axes__` as a user variable. Callers that need accessor syntax
#: re-attach them to their own local dict via `with_resolver_tables`.
RESOLVER_TABLES = ("__wp_axes__", "__wp_picks__", "__wp_negatives__")
def with_resolver_tables(
stripped: dict[str, Any], source: dict[str, Any]
) -> dict[str, Any]:
"""Re-attach the accessor tables to an already-stripped ctx.
`$var.0` and `$var.AXIS` resolve against `__wp_picks__` / `__wp_axes__`,
which `strip_internals` removes along with every other `__` key. Without
them the assembler rendered both accessors as literal text while the same
template resolved one node upstream. The returned dict is for a resolver
call only — never for anything that enumerates variables.
"""
out = dict(stripped)
for key in RESOLVER_TABLES:
if key in source:
out[key] = source[key]
return out
def strip_internals(ctx: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of ``ctx`` with engine-only + user-flagged-internal keys removed.
+438
View File
@@ -0,0 +1,438 @@
"""Library database backups: create, list, prune, delete, staged restore.
Backups live in ``<db file parent>/backups/`` — next to the database they
protect, so moving the library (Settings → Database location) is the only
thing that separates them, and a user who backs up "the wildcard-pipeline
folder" gets both.
File names carry everything we need to know about a backup, so there is no
index file to drift out of sync with the directory::
wildcard-pipeline-YYYYMMDD-HHMMSS-<reason>[-N].db
(UTC timestamp; ``-N`` only when two backups land in the same second.)
Reasons:
- ``manual`` — the user pressed "Back up now". Never pruned: the user
made it deliberately, so only the user deletes it.
- ``daily`` — the background scheduler (``maybe_daily_backup``).
- ``pre-migration`` — taken by ``engine/db/migrations.py:migrate`` right
before it applies schema changes to an existing library.
- ``pre-restore`` — the live DB, saved right before a restore overwrote it,
so a restore is itself undoable.
Copies go through SQLite's online backup API rather than a file copy: the
library runs in WAL mode, where committed data can sit in ``-wal`` rather
than the main file, and a plain copy of the main file would silently lose it.
Restore is staged, like the DB location move: the request only records the
chosen backup in the sidecar (``engine/db/config.py``), and
``execute_pending_restore`` swaps the file at the next start, before any
connection opens. Swapping a SQLite file out from under live connections is
how databases get corrupted.
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import sqlite3
import tempfile
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from engine import prefs
from engine.db import config as db_config
from engine.db.connection import resolve_db_path
logger = logging.getLogger(__name__)
BACKUPS_DIRNAME = "backups"
REASONS = ("manual", "daily", "pre-migration", "pre-restore")
#: Reasons `prune` may delete. Manual backups are the user's, not ours.
AUTOMATIC_REASONS = frozenset({"daily", "pre-migration", "pre-restore"})
DAILY_INTERVAL = timedelta(hours=24)
SCHEDULER_PERIOD_S = 3600.0
_NAME_RE = re.compile(
r"^wildcard-pipeline-(\d{8})-(\d{6})-"
r"(manual|daily|pre-migration|pre-restore)(?:-(\d+))?\.db$"
)
class BackupNotFound(LookupError):
"""The named backup isn't a valid backup file in the backups dir."""
def backups_dir(db_path: Path) -> Path:
"""Where backups of ``db_path`` live. Not created until one is written."""
return Path(db_path).parent / BACKUPS_DIRNAME
def _parse_name(name: str) -> tuple[datetime, str, int] | None:
m = _NAME_RE.fullmatch(name)
if not m:
return None
try:
ts = datetime.strptime(m.group(1) + m.group(2), "%Y%m%d%H%M%S")
except ValueError:
return None
return ts.replace(tzinfo=timezone.utc), m.group(3), int(m.group(4) or 1)
def _new_backup_path(directory: Path, reason: str, now: datetime | None = None) -> Path:
"""First free ``wildcard-pipeline-<ts>-<reason>[-N].db`` in ``directory``."""
stamp = (now or datetime.now(timezone.utc)).strftime("%Y%m%d-%H%M%S")
base = f"wildcard-pipeline-{stamp}-{reason}"
candidate = directory / f"{base}.db"
n = 2
while candidate.exists():
candidate = directory / f"{base}-{n}.db"
n += 1
return candidate
def _entry(path: Path) -> dict[str, Any] | None:
parsed = _parse_name(path.name)
if parsed is None or not path.is_file():
return None
ts, reason, _ = parsed
try:
size = path.stat().st_size
except OSError:
return None
return {
"name": path.name,
"size": size,
"created_at": ts.isoformat().replace("+00:00", "Z"),
"reason": reason,
}
def _sort_key(path: Path) -> tuple[datetime, int]:
parsed = _parse_name(path.name)
assert parsed is not None
return parsed[0], parsed[2]
def _check_reason(reason: str) -> None:
if reason not in REASONS:
raise ValueError(f"unknown backup reason {reason!r}; expected one of {REASONS}")
def create_backup(
db_path: Path,
reason: str,
*,
conn: sqlite3.Connection | None = None,
) -> dict[str, Any]:
"""Write a consistent snapshot of ``db_path`` and return its list entry.
``conn`` lets a caller that already holds a connection (the migration
runner) back up through it instead of opening a second one. The snapshot
is written to a temp file and renamed into place, so a half-written
backup never shows up in the list.
"""
_check_reason(reason)
db_path = Path(db_path)
if conn is None and not db_path.is_file():
raise FileNotFoundError(f"database {db_path} does not exist")
directory = backups_dir(db_path)
directory.mkdir(parents=True, exist_ok=True)
target = _new_backup_path(directory, reason)
fd, tmp_str = tempfile.mkstemp(prefix=".backup-", suffix=".db", dir=str(directory))
os.close(fd)
tmp = Path(tmp_str)
try:
src = conn if conn is not None else sqlite3.connect(str(db_path))
try:
dst = sqlite3.connect(str(tmp))
try:
src.backup(dst)
finally:
dst.close()
finally:
if conn is None:
src.close()
os.replace(tmp, target)
except BaseException:
tmp.unlink(missing_ok=True)
raise
entry = _entry(target)
assert entry is not None
logger.info("wp_db_backup: wrote %s", target)
return entry
def _backup_paths(db_path: Path) -> list[Path]:
"""Valid backup files, newest first."""
directory = backups_dir(db_path)
if not directory.is_dir():
return []
paths = [p for p in directory.iterdir() if p.is_file() and _parse_name(p.name)]
paths.sort(key=_sort_key, reverse=True)
return paths
def list_backups(db_path: Path) -> list[dict[str, Any]]:
"""Every backup of ``db_path``, newest first."""
out = []
for p in _backup_paths(db_path):
e = _entry(p)
if e is not None:
out.append(e)
return out
def prune(db_path: Path, keep: int) -> list[str]:
"""Delete the oldest AUTOMATIC backups beyond ``keep``; return their names.
Manual backups neither count toward ``keep`` nor get deleted.
"""
keep = max(0, int(keep))
automatic = [p for p in _backup_paths(db_path)
if _parse_name(p.name)[1] in AUTOMATIC_REASONS] # type: ignore[index]
removed: list[str] = []
for p in automatic[keep:]:
try:
p.unlink()
removed.append(p.name)
except OSError:
logger.exception("wp_db_backup: could not prune %s", p)
return removed
def resolve_backup(db_path: Path, name: str) -> Path:
"""Map a client-supplied backup name to its file, or raise BackupNotFound.
SECURITY: ``name`` comes from an unauthenticated HTTP request and ends up
in ``unlink`` / a file copy over the live DB. It must be a bare file name
matching the backup pattern (which admits no separators or ``..``) and
must resolve to a file directly inside the backups dir.
"""
if not isinstance(name, str) or _parse_name(name) is None:
raise BackupNotFound(name)
directory = backups_dir(db_path).resolve()
candidate = (directory / name).resolve()
if candidate.parent != directory or not candidate.is_file():
raise BackupNotFound(name)
return candidate
def delete_backup(db_path: Path, name: str) -> None:
"""Delete one backup by name; raises BackupNotFound for anything else."""
resolve_backup(db_path, name).unlink()
def _newest_age(db_path: Path, now: datetime) -> timedelta | None:
paths = _backup_paths(db_path)
if not paths:
return None
return now - _sort_key(paths[0])[0]
def maybe_daily_backup(
db_path: Path | None = None,
*,
now: datetime | None = None,
) -> dict[str, Any] | None:
"""Take a ``daily`` backup when one is due; return it, or None.
Due = backups + daily backups enabled, the DB exists, and the newest
backup of ANY reason is at least 24h old (a manual backup this morning
already covers today).
"""
cfg = prefs.backups()
if not (cfg["enabled"] and cfg["daily"]):
return None
db_path = Path(db_path) if db_path is not None else resolve_db_path()
if not db_path.is_file():
return None
now = now or datetime.now(timezone.utc)
age = _newest_age(db_path, now)
if age is not None and age < DAILY_INTERVAL:
return None
entry = create_backup(db_path, "daily")
prune(db_path, cfg["keep"])
return entry
def backup_before_migration(conn: sqlite3.Connection) -> dict[str, Any] | None:
"""Called by `migrate` before applying pending migrations to a library
that already has some. Never raises: a failed backup is logged and the
migration goes ahead, because refusing to start is worse than the risk
the backup was insuring against.
"""
try:
if not prefs.backups()["enabled"]:
return None
db_file = _main_db_file(conn)
if db_file is None:
return None
entry = create_backup(db_file, "pre-migration", conn=conn)
prune(db_file, prefs.backups()["keep"])
return entry
except Exception: # noqa: BLE001 - see docstring
logger.exception("wp_db_backup: pre-migration backup failed; migrating anyway")
return None
def _main_db_file(conn: sqlite3.Connection) -> Path | None:
"""The file behind ``conn``'s main schema, or None for in-memory DBs."""
for row in conn.execute("PRAGMA database_list;").fetchall():
if row[1] == "main":
return Path(row[2]) if row[2] else None
return None
# ── staged restore ───────────────────────────────────────────────────
def stage_restore(db_path: Path, name: str) -> Path:
"""Record a restore of backup ``name`` for the next start."""
path = resolve_backup(db_path, name)
db_config.set_pending_restore(path)
return path
def cancel_restore() -> None:
db_config.clear_pending_restore()
def pending_restore_name(db_path: Path) -> str | None:
"""Name of the staged restore's backup, if one is staged for this DB."""
pr = db_config.load().get("pending_restore")
if not pr:
return None
src = Path(pr["from"])
try:
resolve_backup(db_path, src.name)
except BackupNotFound:
return None
return src.name
def _copy_live_db_file(db_path: Path) -> None:
"""Fallback pre-restore snapshot: plain file copy (nothing is open yet)."""
directory = backups_dir(db_path)
directory.mkdir(parents=True, exist_ok=True)
shutil.copy2(db_path, _new_backup_path(directory, "pre-restore"))
def execute_pending_restore() -> None:
"""If a restore is staged, run it and clear the entry. Never raises.
Runs at plugin load, right after ``execute_pending_move`` and before any
connection opens. Order: snapshot the live DB as ``pre-restore`` (so the
restore is undoable), drop the live ``-wal`` / ``-shm`` (they belong to the
file being replaced — left behind, SQLite would replay them onto the
restored one), then copy the backup into place via temp + rename.
"""
try:
_execute_pending_restore()
except Exception: # noqa: BLE001 - see docstring
logger.exception("wp_db_restore: failed")
def _execute_pending_restore() -> None:
pr = db_config.load().get("pending_restore")
if not pr:
return
db_path = resolve_db_path()
src = Path(pr["from"])
try:
src = resolve_backup(db_path, src.name)
except BackupNotFound:
# A backup that is gone (or a sidecar pointing somewhere else) will
# never become restorable; keeping the entry would retry forever.
logger.error("wp_db_restore: backup %s not found in %s, dropping request",
pr["from"], backups_dir(db_path))
db_config.clear_pending_restore()
return
if db_path.is_file():
try:
create_backup(db_path, "pre-restore")
except Exception: # noqa: BLE001 - fall back to a raw copy
logger.exception("wp_db_restore: online pre-restore backup failed, copying file")
try:
_copy_live_db_file(db_path)
except OSError:
# Without a safety copy, overwriting would be irreversible.
logger.exception("wp_db_restore: could not save current DB; not restoring")
return
for suffix in ("-wal", "-shm"):
Path(str(db_path) + suffix).unlink(missing_ok=True)
db_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_str = tempfile.mkstemp(prefix=".restore-", suffix=".db", dir=str(db_path.parent))
os.close(fd)
tmp = Path(tmp_str)
try:
shutil.copyfile(src, tmp)
os.replace(tmp, db_path)
except OSError:
tmp.unlink(missing_ok=True)
logger.exception("wp_db_restore: copying %s over %s failed", src, db_path)
return
db_config.clear_pending_restore()
try:
prune(db_path, prefs.backups()["keep"])
except Exception: # noqa: BLE001 - pruning is housekeeping
logger.exception("wp_db_restore: prune failed")
logger.info("wp_db_restore: restored %s -> %s", src, db_path)
# ── daily scheduler ─────────────────────────────────────────────────
_scheduler_lock = threading.Lock()
_scheduler: threading.Thread | None = None
_scheduler_stop = threading.Event()
def _safe_daily() -> None:
try:
maybe_daily_backup()
except Exception: # noqa: BLE001 - a failed backup must not kill the loop
logger.exception("wp_db_backup: daily backup failed")
def _scheduler_loop(period: float) -> None:
while not _scheduler_stop.wait(period):
_safe_daily()
def start_daily_scheduler(period: float = SCHEDULER_PERIOD_S) -> bool:
"""Check for a due daily backup now, then hourly on a daemon thread.
Hourly checks against a 24h interval, rather than one 24h timer, so a
machine that sleeps or a ComfyUI that restarts often still gets its
backup roughly on schedule. Idempotent: returns False if already running.
"""
global _scheduler
with _scheduler_lock:
if _scheduler is not None and _scheduler.is_alive():
return False
_scheduler_stop.clear()
_safe_daily()
_scheduler = threading.Thread(
target=_scheduler_loop, args=(period,),
name="wp-daily-backup", daemon=True,
)
_scheduler.start()
return True
def stop_daily_scheduler() -> None:
"""Stop the scheduler thread (tests)."""
global _scheduler
with _scheduler_lock:
_scheduler_stop.set()
if _scheduler is not None:
_scheduler.join(timeout=5)
_scheduler = None
+35 -1
View File
@@ -1,4 +1,4 @@
"""Sidecar config for DB location preference + pending move state.
"""Sidecar config for DB location preference + pending move/restore state.
Lives at ``<plugin>/db-config.json`` so the resolver can read it BEFORE
opening any DB connection (chicken-and-egg: the preference about where
@@ -35,6 +35,11 @@ class PendingMove(TypedDict, total=False):
class DbConfig(TypedDict, total=False):
preference: Preference
pending_move: dict[str, str] # {"from": "...", "to": "...", "mode": "..."}
# {"from": "<absolute backup path>"} — a restore staged from Settings,
# executed by `engine/db/backups.py:execute_pending_restore` at the next
# start, before any connection opens (a live SQLite file can't safely be
# swapped out from under open connections).
pending_restore: dict[str, str]
def _read_raw(path: Path) -> dict[str, Any]:
@@ -73,6 +78,11 @@ def load(path: Path | None = None) -> DbConfig:
if (isinstance(mode, str) and mode in _VALID_MODES
and isinstance(src, str) and isinstance(dst, str)):
out["pending_move"] = {"from": src, "to": dst, "mode": mode}
pr = raw.get("pending_restore")
if isinstance(pr, dict):
src = pr.get("from")
if isinstance(src, str) and src:
out["pending_restore"] = {"from": src}
return out
@@ -87,6 +97,9 @@ def save(config: DbConfig, path: Path | None = None) -> None:
pm = config.get("pending_move")
if isinstance(pm, dict) and {"from", "to", "mode"} <= pm.keys():
out["pending_move"] = {"from": pm["from"], "to": pm["to"], "mode": pm["mode"]}
pr = config.get("pending_restore")
if isinstance(pr, dict) and isinstance(pr.get("from"), str) and pr["from"]:
out["pending_restore"] = {"from": pr["from"]}
fd, tmp_str = tempfile.mkstemp(prefix=".db-config-", dir=str(path.parent))
tmp_path = Path(tmp_str)
try:
@@ -107,6 +120,27 @@ def clear_pending_move(path: Path | None = None) -> None:
save(cfg, path)
def set_pending_restore(backup_path: Path, path: Path | None = None) -> None:
"""Stage a restore from ``backup_path``; preserves the other fields.
Callers validate the path (it must be a file in the backups dir) — this
layer only persists it.
"""
path = _resolve_sidecar(path)
cfg = load(path)
cfg["pending_restore"] = {"from": str(backup_path)}
save(cfg, path)
def clear_pending_restore(path: Path | None = None) -> None:
"""Remove just the pending_restore field; preserve everything else."""
path = _resolve_sidecar(path)
cfg = load(path)
if "pending_restore" in cfg:
cfg.pop("pending_restore", None)
save(cfg, path)
def plugin_root() -> Path:
"""Expose the plugin root so the resolver can compute the `root` location."""
return _PLUGIN_ROOT
+36 -3
View File
@@ -33,6 +33,17 @@ from pathlib import Path
DB_FILENAME = "wildcard-pipeline.db"
#: Our own folder under ComfyUI's user directory.
#:
#: Everything of ours that must survive an extension reinstall lives here: the
#: library database and the downloaded tag list, with room for whatever comes
#: next. They used to sit loose in `<ComfyUI>/user/` alongside ComfyUI's own
#: files, which was fine when there was one of them and stopped being fine at
#: two. One folder is also one thing to back up, and one thing to delete.
#:
#: `engine/db/relocate.py` moves pre-existing files in on first boot.
WP_DATA_DIRNAME = "wildcard-pipeline"
def _comfyui_user_dir_from_api() -> Path | None:
"""Use ComfyUI's `folder_paths.get_user_directory()` when the host
@@ -102,6 +113,28 @@ def _load_sidecar() -> dict:
return dict(load())
def comfyui_user_dir() -> Path | None:
"""ComfyUI's user directory, or ``None`` when it cannot be detected.
The same detector chain the ``user`` DB location uses, exposed because it
is not database-specific — anything of ours that must survive an extension
reinstall belongs under here rather than in the package folder.
"""
return _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
def wp_data_dir() -> Path | None:
"""Our folder inside ComfyUI's user directory, or ``None`` when the user
directory cannot be detected.
Does NOT create the directory — callers that write do that, so a read-only
probe (the Settings panel asking "where would this live?") never has the
side effect of making a folder the user did not ask for.
"""
base = comfyui_user_dir()
return (base / WP_DATA_DIRNAME) if base else None
def user_location_path() -> Path | None:
"""Return the ``user`` preference target path, or ``None`` if the
ComfyUI user dir can't be detected.
@@ -111,7 +144,7 @@ def user_location_path() -> Path | None:
list all three potential locations regardless of which one is
currently active (or whether one is detectable at all)."""
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
return (comfy_user / DB_FILENAME) if comfy_user else None
return (comfy_user / WP_DATA_DIRNAME / DB_FILENAME) if comfy_user else None
def global_location_path() -> Path:
@@ -153,14 +186,14 @@ def resolve_db_path_with_source() -> tuple[Path, str]:
if preference == "user":
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
if comfy_user is not None:
return comfy_user / DB_FILENAME, "user"
return comfy_user / WP_DATA_DIRNAME / DB_FILENAME, "user"
# Detection failed — fall through to the default chain rather
# than crash. Last-ditch fallback below will catch it.
# Default — same as explicit "user" preference.
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
if comfy_user is not None:
return comfy_user / DB_FILENAME, "user"
return comfy_user / WP_DATA_DIRNAME / DB_FILENAME, "user"
# Last-ditch fallback. The design folds the old "legacy" label into
# "global" because the path is identical and the UI has no separate
+126 -10
View File
@@ -14,6 +14,7 @@ import graph.
"""
from __future__ import annotations
import hashlib
import importlib.util
import re
import sqlite3
@@ -26,6 +27,24 @@ _SQL_DIR = Path(__file__).parent / "migrations_sql"
_VERSION_RE = re.compile(r"^(\d{3})_.+\.(sql|py)$")
class SchemaAheadError(RuntimeError):
"""The database was migrated by a build this one cannot safely read.
Raised instead of letting the runner's "skip anything already applied"
rule quietly hand a foreign schema to code that expects its own.
"""
def _checksum(path: Path) -> str:
"""sha256 of a migration file's bytes.
Bytes, not parsed content: a `.py` migration's behaviour lives in code the
runner `exec`s, so any edit at all is a different migration regardless of
whether the SQL it emits looks the same.
"""
return hashlib.sha256(path.read_bytes()).hexdigest()
def _migrations_table_exists(conn: sqlite3.Connection) -> bool:
row = conn.execute(
"SELECT name FROM sqlite_master "
@@ -75,9 +94,112 @@ def _load_py_migration(path: Path) -> Callable[[sqlite3.Connection], object]:
return up # type: ignore[no-any-return]
def migrate(conn: sqlite3.Connection) -> None:
"""Apply all pending migrations. Safe to call repeatedly."""
def head_version() -> int:
"""Highest migration version this build ships."""
pairs = _discover()
return pairs[-1][0] if pairs else 0
def _has_checksum_column(conn: sqlite3.Connection) -> bool:
cur = conn.execute("PRAGMA table_info(migrations);")
return any(row[1] == "checksum" for row in cur.fetchall())
def _record(conn: sqlite3.Connection, version: int, mig_file: Path) -> None:
"""Insert the applied-migration row, with a checksum when we can store one.
Migration 017 is what adds the column, so on a fresh database every
migration before it inserts without one. Those rows stay NULL forever —
see the 017 docblock for why backfilling them would defeat the check.
"""
if _has_checksum_column(conn):
conn.execute(
"INSERT INTO migrations(version, applied_at, checksum) VALUES(?, ?, ?);",
(version, now_iso(), _checksum(mig_file)),
)
else:
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)
def assert_schema_compatible(conn: sqlite3.Connection) -> None:
"""Refuse to touch a database some other build's migrations shaped.
Two distinct failures, both silent before this existed:
1. The database is AHEAD — migrated by a newer build. Every migration is
`<= MAX(version)`, so the runner does nothing and the caller reads a
schema it has never seen.
2. The database is at the SAME version but a DIFFERENT shape, because a
migration file changed after it had already run somewhere. Version
numbers match, so nothing compares anything.
Both are cumulative: each subsequent write goes through code whose
assumptions about the row shape are wrong. Raising is recoverable;
continuing is not.
"""
if not _migrations_table_exists(conn):
return
applied = current_version(conn)
head = head_version()
if applied > head:
raise SchemaAheadError(
f"This database was migrated to version {applied}, but this build only "
f"knows up to {head}. It was almost certainly written by a newer "
f"version of Wildcard Pipeline. Reading it here could corrupt your "
f"library — install the newer version again, or restore a backup."
)
if not _has_checksum_column(conn):
return
known = dict(_discover())
rows = conn.execute(
"SELECT version, checksum FROM migrations WHERE checksum IS NOT NULL;"
).fetchall()
for version, checksum in rows:
mig_file = known.get(int(version))
# Unknown version is the `applied > head` case, already handled above.
if mig_file is None:
continue
if _checksum(mig_file) != checksum:
raise SchemaAheadError(
f"Migration {int(version):03d} was applied by a build whose copy of "
f"it differs from this one's. The version numbers match but the "
f"schemas may not, so this build cannot safely read your library. "
f"Restore a backup taken before that build ran."
)
def _backup_if_upgrading(conn: sqlite3.Connection, applied: int) -> None:
"""Snapshot an existing library before this build changes its schema.
Only when there is something to lose AND something about to change: a
fresh database (``applied == 0``) has no data, an up-to-date one has no
pending step, and an in-memory one has no file. `backup_before_migration`
swallows its own failures — a backup problem never blocks a migration.
"""
if applied <= 0:
return
if not any(version > applied for version, _ in _discover()):
return
from engine.db.backups import backup_before_migration
backup_before_migration(conn)
def migrate(conn: sqlite3.Connection) -> None:
"""Apply all pending migrations. Safe to call repeatedly.
Guards first: applying this build's migrations on top of a schema another
build shaped is how a mismatch becomes permanent.
"""
assert_schema_compatible(conn)
applied = current_version(conn)
_backup_if_upgrading(conn, applied)
for version, mig_file in _discover():
if version <= applied:
continue
@@ -85,10 +207,7 @@ def migrate(conn: sqlite3.Connection) -> None:
up = _load_py_migration(mig_file)
with conn:
up(conn)
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)
_record(conn, version, mig_file)
else:
sql = mig_file.read_text(encoding="utf-8")
with conn:
@@ -96,7 +215,4 @@ def migrate(conn: sqlite3.Connection) -> None:
stmt = stmt.strip()
if stmt:
conn.execute(stmt)
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)
_record(conn, version, mig_file)
@@ -0,0 +1,41 @@
"""Migration 017 — record WHICH migration ran, not just how many.
``migrations`` tracked only ``version``, and the runner skips anything
``<= MAX(version)``. That makes an integer the entire identity of a
schema, which it is not:
- A build whose head is 015 opened against a DB migrated to 016 skips
every migration, finds nothing to do, and reads a schema it has never
seen. Additive columns survive; a shape change does not.
- Worse, two builds can both call something "016" and mean different
things — a reworked migration, a hand-edited file, a branch that never
shipped. The version matches, so nothing anywhere notices.
Both are silent, and both corrupt cumulatively: every subsequent write
goes through code whose assumptions about the row shape are wrong.
``checksum`` fixes it by storing the sha256 of the migration file that
actually ran. ``assert_schema_compatible`` then compares applied
checksums against the files this build ships, so a divergence is a
loud, recoverable error instead of silence.
Nullable on purpose: rows applied before this migration existed have no
recorded checksum and cannot retroactively grow one. The guard skips
them rather than guessing — a NULL means "unknown", never "mismatched".
Idempotent via ``PRAGMA table_info`` so tests that rewind the
``migrations`` table can re-run it against a populated DB.
"""
from __future__ import annotations
import sqlite3
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
cur = conn.execute(f"PRAGMA table_info({table});")
return any(row[1] == column for row in cur.fetchall())
def up(conn: sqlite3.Connection) -> None:
if not _has_column(conn, "migrations", "checksum"):
conn.execute("ALTER TABLE migrations ADD COLUMN checksum TEXT;")
@@ -0,0 +1,35 @@
-- Test Runner scenarios — saved stacks the Test Runner re-runs on demand.
--
-- A scenario references library modules and bundles LIVE by id (the stack
-- JSON holds `{module: id}` / `{bundle: id}` items), so editing a module
-- changes the next run. That is the point: a stored baseline lets the
-- runner say which variables moved since. Local-only by design, never
-- exported or published.
--
-- Columns:
-- stack JSON list of stack items, run top to bottom
-- pins JSON object of pinned $var values (stand-ins for upstream)
-- seeds JSON seed spec (from+count, random+count, or list)
-- output_var the variable the runner treats as the prompt, or NULL
-- baseline JSON snapshot of a run to compare against, or NULL
-- last_run JSON summary of the most recent run, or NULL
--
-- NOTE: the migration runner splits on the semicolon char, so comments
-- must avoid it.
CREATE TABLE IF NOT EXISTS test_scenarios (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
is_pinned INTEGER NOT NULL DEFAULT 0,
stack TEXT NOT NULL DEFAULT '[]',
pins TEXT NOT NULL DEFAULT '{}',
seeds TEXT NOT NULL DEFAULT '{"from": 0, "count": 100}',
output_var TEXT,
baseline TEXT,
last_run TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_test_scenarios_updated_at ON test_scenarios(updated_at DESC);
@@ -0,0 +1,32 @@
"""Migration 019 -- negative_template column on saved prompt templates.
Send-to-negative gives the PromptAssembler a second box, the negative
template (``$vars`` plus the reserved ``$negatives`` slot). A saved
template stores both boxes, so ``templates`` gains ``negative_template``
(TEXT, nullable, no default).
NULL is meaningful: a template saved before this migration has no
negative, and loading it leaves the Assembler's negative box as it was
instead of blanking it. A template saved afterwards stores a string,
even an empty one, so loading it sets the box.
Row-level column, not a payload shape: no ``schema_version`` bump.
Idempotent: the ALTER is gated on ``PRAGMA table_info``. The migration
runner's version table is the primary re-run guard; the column check
exists for test fixtures that rewind ``migrations``.
"""
from __future__ import annotations
import sqlite3
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
cur = conn.execute(f"PRAGMA table_info({table});")
return any(row[1] == column for row in cur.fetchall())
def up(conn: sqlite3.Connection) -> None:
with conn:
if not _has_column(conn, "templates", "negative_template"):
conn.execute("ALTER TABLE templates ADD COLUMN negative_template TEXT;")
+103
View File
@@ -0,0 +1,103 @@
"""One-time move of our files from ``<ComfyUI>/user/`` into ``<user>/wildcard-pipeline/``.
The library database and the downloaded tag list used to sit loose in ComfyUI's
user directory. Existing installs have real data there, so introducing the
subfolder is only half the change — without this the extension would silently
come up with an empty library and no tag list, which reads as data loss even
though the files are still on disk.
Design constraints, in the order they mattered:
1. **Never overwrite.** If something already exists at the destination, the
legacy file is left exactly where it is and reported as skipped. A
destination file means either a completed migration or a deliberate
user-placed file; both outsrank anything we could infer.
2. **Run before the database is opened.** SQLite keeps ``-wal`` and ``-shm``
sidecars next to the main file, and moving the set out from under an open
connection corrupts it. ``__init__.py`` calls this at import, before routes
are registered and before anything connects.
3. **Move the sidecars with it.** A ``-wal`` left behind holds committed
transactions that have not been checkpointed into the main file yet; moving
the ``.db`` alone can lose the most recent writes.
4. **Never raise.** A failed migration must degrade to "extension starts with
the old path still in place", not to "ComfyUI fails to load the extension".
Every failure is reported in the return value for the caller to log.
"""
from __future__ import annotations
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from engine.db.connection import DB_FILENAME, comfyui_user_dir, wp_data_dir
#: Kept in sync with `wp_api/tags.py::TAG_FILE_NAME`. Duplicated rather than
#: imported because `engine/` must not import from `wp_api/` — the directory
#: contract in CLAUDE.md keeps the engine free of ComfyUI-facing modules, and
#: `wp_api` imports aiohttp.
TAG_FILE_NAME = "wildcard-pipeline-tags.csv"
#: SQLite's write-ahead-log sidecars. Moved with the database, never alone.
_DB_SIDECAR_SUFFIXES = ("-wal", "-shm")
@dataclass
class RelocationReport:
"""What happened, for the caller to log. Empty everywhere = nothing to do."""
moved: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
failed: list[str] = field(default_factory=list)
@property
def did_anything(self) -> bool:
return bool(self.moved or self.skipped or self.failed)
def _move_one(src: Path, dst: Path, report: RelocationReport) -> None:
"""Move ``src`` to ``dst`` if and only if ``src`` exists and ``dst`` does not."""
if not src.exists():
return
if dst.exists():
report.skipped.append(f"{src.name} (already present at destination)")
return
try:
dst.parent.mkdir(parents=True, exist_ok=True)
# `shutil.move` rather than `Path.rename`: the user directory and our
# subfolder are normally the same filesystem, but a user dir that is a
# mount or a symlink elsewhere would make rename fail with EXDEV.
shutil.move(str(src), str(dst))
report.moved.append(src.name)
except Exception as exc: # noqa: BLE001 - see module docstring, item 4
report.failed.append(f"{src.name}: {exc}")
def migrate_user_data() -> RelocationReport:
"""Move the database and tag list into ``<user>/wildcard-pipeline/``.
Idempotent: once the files are at the destination every subsequent call
finds nothing to move and returns an empty report. Safe to call on every
boot, which is exactly what happens.
"""
report = RelocationReport()
base = comfyui_user_dir()
target = wp_data_dir()
if base is None or target is None:
# No detectable user directory. `resolve_db_path_with_source` falls
# back to `~/.comfyui/` in this case, which was never inside the user
# dir, so there is nothing here to move.
return report
# Database first, sidecars immediately after it. Order matters only for
# readability of the report; the "never overwrite" rule makes each move
# independent.
_move_one(base / DB_FILENAME, target / DB_FILENAME, report)
for suffix in _DB_SIDECAR_SUFFIXES:
_move_one(
base / f"{DB_FILENAME}{suffix}",
target / f"{DB_FILENAME}{suffix}",
report,
)
_move_one(base / TAG_FILE_NAME, target / TAG_FILE_NAME, report)
return report
+281 -8
View File
@@ -382,7 +382,15 @@ class ModuleRepository:
.replace("%", "\\%")
.replace("_", "\\_")
)
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
# Match the name OR the uuid — searching by id is the fast path for
# debugging library linkage (finding the row a workflow's @{id} /
# constraint source/target points at). NOCASE so an 8-hex id typed
# in any case still matches.
clauses.append(
"(name LIKE ? ESCAPE '\\' COLLATE NOCASE "
"OR id LIKE ? ESCAPE '\\' COLLATE NOCASE)"
)
params.append(f"%{escaped}%")
params.append(f"%{escaped}%")
if favorites_only:
clauses.append("is_favorite = 1")
@@ -414,6 +422,57 @@ class ModuleRepository:
rows = self._conn.execute(sql, params).fetchall()
return [_row_to_module(r) for r in rows]
def library_stamp(self) -> str:
"""A cheap value that changes whenever any module's payload changes.
Exists so the drift endpoint can answer "nothing changed" WITHOUT
building the hash map. That map costs a `json.loads` of every payload
in the library, and the drift poll asks for it every five seconds per
open tab — so the expensive part has to sit behind this, not in front
of it.
Three columns, each covering what the others miss:
- `COUNT(*)` — any insert or delete.
- `SUM(version)`— any payload edit; `update` does `version = version + 1`.
- `MAX(updated_at)` — backstop for a delete+insert that happened to
leave count and version-sum unchanged.
Note what deliberately does NOT move it: `set_community_origin` and the
favorite toggle write without bumping `version`, and neither changes a
`payload_hash`. A stamp that ignored them would be wrong; a stamp that
moved on them would just cost every client a pointless refetch.
"""
row = self._conn.execute(
"SELECT COUNT(*) AS n, COALESCE(SUM(version), 0) AS v, "
"COALESCE(MAX(updated_at), '') AS t FROM modules;"
).fetchone()
return f"{row['n']}:{row['v']}:{row['t']}"
def list_hash_rows(self) -> list[dict[str, Any]]:
"""`{id, type, payload_hash}` for every module — the drift map.
Deliberately not `list()`. That selects every column and runs each row
through `_row_to_module`, which parses `tags`, reads the community and
content-rating columns and builds a seventeen-key dict — all of it
discarded here. This selects the three columns the drift map needs.
The `payload` parse survives because `payload_hash` is computed, not
stored (unlike `bundles`, which has the column). Removing that too
means a schema migration; the stamp above makes it moot in the steady
state, which is the case that actually repeats.
"""
rows = self._conn.execute(
"SELECT id, type, payload FROM modules;"
).fetchall()
return [
{
"id": r["id"],
"type": r["type"],
"payload_hash": payload_hash(json.loads(r["payload"])),
}
for r in rows
]
def count(
self,
*,
@@ -767,7 +826,15 @@ class BundleRepository:
.replace("%", "\\%")
.replace("_", "\\_")
)
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
# Match the name OR the uuid — searching by id is the fast path for
# debugging library linkage (finding the row a workflow's @{id} /
# constraint source/target points at). NOCASE so an 8-hex id typed
# in any case still matches.
clauses.append(
"(name LIKE ? ESCAPE '\\' COLLATE NOCASE "
"OR id LIKE ? ESCAPE '\\' COLLATE NOCASE)"
)
params.append(f"%{escaped}%")
params.append(f"%{escaped}%")
if favorites_only:
clauses.append("is_favorite = 1")
@@ -797,6 +864,30 @@ class BundleRepository:
rows = self._conn.execute(sql, params).fetchall()
return [_row_to_bundle(r) for r in rows]
def library_stamp(self) -> str:
"""Cheap change signal for the bundle drift poll.
See `ModuleRepository.library_stamp` — same three columns, same
reasoning."""
row = self._conn.execute(
"SELECT COUNT(*) AS n, COALESCE(SUM(version), 0) AS v, "
"COALESCE(MAX(updated_at), '') AS t FROM bundles;"
).fetchone()
return f"{row['n']}:{row['v']}:{row['t']}"
def list_hash_rows(self) -> list[dict[str, Any]]:
"""`{id, payload_hash}` for every bundle.
Unlike modules, `bundles.payload_hash` is a real stored column, so
this reads two columns and parses nothing at all. `list()` here would
have selected every column and run `_row_to_bundle` over each row,
which parses `tags` AND `children` per bundle for a map that wants
neither."""
rows = self._conn.execute(
"SELECT id, payload_hash FROM bundles;"
).fetchall()
return [{"id": r["id"], "payload_hash": r["payload_hash"]} for r in rows]
def count(
self,
*,
@@ -817,6 +908,16 @@ class TemplateNotFound(LookupError):
"""Raised when a requested template id does not exist."""
def _template_negative(row: sqlite3.Row) -> str | None:
"""Migration 019's column. NULL = saved before negatives existed (loading
leaves the Assembler's negative box alone). Pre-migration fixtures lack
the column entirely."""
try:
return row["negative_template"]
except (IndexError, KeyError):
return None
def _row_to_template(row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
@@ -826,6 +927,7 @@ def _row_to_template(row: sqlite3.Row) -> dict[str, Any]:
"tags": json.loads(row["tags"]),
"is_favorite": bool(row["is_favorite"]),
"template_string": row["template_string"],
"negative_template": _template_negative(row),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
@@ -855,6 +957,7 @@ class TemplateRepository:
category_id: str | None = None,
tags: list[str] | None = None,
is_favorite: bool = False,
negative_template: str | None = None,
) -> dict[str, Any]:
tid = self._gen_id()
now = _now()
@@ -862,12 +965,13 @@ class TemplateRepository:
self._conn.execute(
"INSERT INTO templates("
"id, name, description, category_id, tags, "
"is_favorite, template_string, created_at, updated_at"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);",
"is_favorite, template_string, negative_template, "
"created_at, updated_at"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
(
tid, name, description, category_id,
json.dumps(tags or []), int(is_favorite),
template_string, now, now,
template_string, negative_template, now, now,
),
)
return self.get(tid)
@@ -890,6 +994,7 @@ class TemplateRepository:
tags: list[str] | _Unset = _UNSET,
template_string: str | _Unset = _UNSET,
is_favorite: bool | _Unset = _UNSET,
negative_template: str | None | _Unset = _UNSET,
) -> dict[str, Any]:
existing = self.get(template_id)
new = {
@@ -909,18 +1014,25 @@ class TemplateRepository:
"is_favorite": (
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
),
"negative_template": (
existing["negative_template"]
if isinstance(negative_template, _Unset)
else negative_template
),
}
now = _now()
with self._conn:
self._conn.execute(
"UPDATE templates SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, template_string = ?, updated_at = ? "
"is_favorite = ?, template_string = ?, negative_template = ?, "
"updated_at = ? "
"WHERE id = ?;",
(
new["name"], new["description"], new["category_id"],
json.dumps(new["tags"]), int(new["is_favorite"]),
new["template_string"], now, template_id,
new["template_string"], new["negative_template"], now,
template_id,
),
)
return self.get(template_id)
@@ -951,7 +1063,15 @@ class TemplateRepository:
.replace("%", "\\%")
.replace("_", "\\_")
)
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
# Match the name OR the uuid — searching by id is the fast path for
# debugging library linkage (finding the row a workflow's @{id} /
# constraint source/target points at). NOCASE so an 8-hex id typed
# in any case still matches.
clauses.append(
"(name LIKE ? ESCAPE '\\' COLLATE NOCASE "
"OR id LIKE ? ESCAPE '\\' COLLATE NOCASE)"
)
params.append(f"%{escaped}%")
params.append(f"%{escaped}%")
if favorites_only:
clauses.append("is_favorite = 1")
@@ -994,3 +1114,156 @@ class TemplateRepository:
"SELECT COUNT(*) AS n FROM templates" + where, params,
).fetchone()
return int(row["n"]) if row is not None else 0
class ScenarioNotFound(LookupError):
"""Raised when a requested Test Runner scenario id does not exist."""
_DEFAULT_SCENARIO_SEEDS: dict[str, Any] = {"from": 0, "count": 100}
def _json_or_none(raw: str | None) -> Any:
return json.loads(raw) if raw else None
def _row_to_scenario(row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"name": row["name"],
"description": row["description"],
"is_pinned": bool(row["is_pinned"]),
"stack": json.loads(row["stack"]),
"pins": json.loads(row["pins"]),
"seeds": json.loads(row["seeds"]),
"output_var": row["output_var"],
"baseline": _json_or_none(row["baseline"]),
"last_run": _json_or_none(row["last_run"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
class ScenarioRepository:
"""CRUD for saved Test Runner scenarios (migration 018).
A scenario's stack references library modules / bundles by id and is
resolved live at run time, so nothing here snapshots module payloads.
`baseline` and `last_run` are opaque JSON the Test Runner writes; the
repository only stores them. Pure DB — engine-isolated.
"""
def __init__(self, conn: sqlite3.Connection) -> None:
self._conn = conn
def _gen_id(self) -> str:
return secrets.token_hex(_ID_HEX_LEN // 2)
def create(
self,
*,
name: str,
description: str = "",
is_pinned: bool = False,
stack: list[Any] | None = None,
pins: dict[str, str] | None = None,
seeds: dict[str, Any] | None = None,
output_var: str | None = None,
baseline: dict[str, Any] | None = None,
last_run: dict[str, Any] | None = None,
) -> dict[str, Any]:
sid = self._gen_id()
now = _now()
with self._conn:
self._conn.execute(
"INSERT INTO test_scenarios("
"id, name, description, is_pinned, stack, pins, seeds, "
"output_var, baseline, last_run, created_at, updated_at"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
(
sid, name, description, int(is_pinned),
json.dumps(stack or []), json.dumps(pins or {}),
json.dumps(seeds or _DEFAULT_SCENARIO_SEEDS), output_var,
None if baseline is None else json.dumps(baseline),
None if last_run is None else json.dumps(last_run),
now, now,
),
)
return self.get(sid)
def get(self, scenario_id: str) -> dict[str, Any]:
row = self._conn.execute(
"SELECT * FROM test_scenarios WHERE id = ?;", (scenario_id,),
).fetchone()
if row is None:
raise ScenarioNotFound(scenario_id)
return _row_to_scenario(row)
_UPDATABLE = (
"name", "description", "is_pinned", "stack", "pins", "seeds",
"output_var", "baseline", "last_run",
)
def update(self, scenario_id: str, **fields: Any) -> dict[str, Any]:
"""Patch any of `_UPDATABLE`. `baseline` / `last_run` / `output_var`
accept None to clear. Unknown keys raise TypeError."""
unknown = set(fields) - set(self._UPDATABLE)
if unknown:
raise TypeError(f"unknown scenario fields: {sorted(unknown)}")
existing = self.get(scenario_id)
new = {**existing, **fields}
def _opt(value: Any) -> str | None:
return None if value is None else json.dumps(value)
with self._conn:
self._conn.execute(
"UPDATE test_scenarios SET "
"name = ?, description = ?, is_pinned = ?, stack = ?, pins = ?, "
"seeds = ?, output_var = ?, baseline = ?, last_run = ?, updated_at = ? "
"WHERE id = ?;",
(
new["name"], new["description"], int(bool(new["is_pinned"])),
json.dumps(new["stack"]), json.dumps(new["pins"]),
json.dumps(new["seeds"]), new["output_var"],
_opt(new["baseline"]), _opt(new["last_run"]),
_now(), scenario_id,
),
)
return self.get(scenario_id)
def delete(self, scenario_id: str) -> None:
with self._conn:
cur = self._conn.execute(
"DELETE FROM test_scenarios WHERE id = ?;", (scenario_id,),
)
if cur.rowcount == 0:
raise ScenarioNotFound(scenario_id)
def list(self, *, query: str | None = None) -> list[dict[str, Any]]:
"""Pinned first, then most recently updated. `query` matches the
name (case-insensitive substring)."""
sql = "SELECT * FROM test_scenarios"
params: list[Any] = []
if query:
escaped = query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
sql += " WHERE name LIKE ? ESCAPE '\\' COLLATE NOCASE"
params.append(f"%{escaped}%")
sql += " ORDER BY is_pinned DESC, updated_at DESC, id;"
return [_row_to_scenario(r) for r in self._conn.execute(sql, params).fetchall()]
def using(self, *, module_id: str | None = None, bundle_id: str | None = None) -> list[str]:
"""Ids of scenarios whose stack references a module or bundle — the
"used in N scenarios" hint in the module editors."""
key, target = ("module", module_id) if module_id else ("bundle", bundle_id)
if not target:
return []
out: list[str] = []
for row in self._conn.execute("SELECT id, stack FROM test_scenarios;").fetchall():
try:
stack = json.loads(row["stack"])
except (TypeError, ValueError):
continue
if any(isinstance(i, dict) and i.get(key) == target for i in stack):
out.append(row["id"])
return out
+10 -4
View File
@@ -33,7 +33,7 @@ from engine.db.repositories import (
TemplateNotFound,
TemplateRepository,
)
from engine.migrations import CURRENT_SCHEMA_VERSION
from engine.migrations.stamping import schema_version_for_payload
def _walk_inner_bundles(repo: BundleRepository, seed: list[str]) -> list[str]:
@@ -152,9 +152,7 @@ def build_export_payload(
except TemplateNotFound:
pass
return {
"schema_version": CURRENT_SCHEMA_VERSION,
"exported_at": now_iso(),
buckets: dict[str, list[dict[str, Any]]] = {
"bundles": bundles,
"wildcards": wildcards,
"fixed_values": fixed_values,
@@ -164,3 +162,11 @@ def build_export_payload(
"categories": categories,
"templates": templates,
}
# Stamp the lowest version the exported content needs (range grammar 3,
# constraint reach 4, accepts tag axes 5), not the migration-chain head:
# an older extension must refuse a file whose features it can't read.
return {
"schema_version": schema_version_for_payload(buckets),
"exported_at": now_iso(),
**buckets,
}
+376
View File
@@ -0,0 +1,376 @@
"""Pure logic behind ``WP_ImageFilter`` — the pause-and-pick node.
The node sits between two passes (first KSampler → upscaler / hires pass) and
lets the user choose which images go on. Everything wired through it follows
the same picks so its outputs stay lined up for the next sampler: the latents
of the picked images, the conditionings and prompt strings of the frames they
came from, the masks, the pipeline context.
Shapes. With ``is_input_list`` the node receives every input as a list. The
``images`` list sets the frames: ``images[f]`` is one IMAGE batch, so a plain
batch is one frame of B images and a Context Loop of N iterations is N frames.
A pick is ``(frame, image)``. Each other slot is aligned to the frames the way
ComfyUI aligns lists (one item per frame, or its last item repeated) and then:
- a value batched like its frame's images (latent ``samples``, a mask, a
conditioning tensor whose first dimension equals the frame's batch) is
sliced to the picked images;
- anything else (a string, a context, a batch-1 conditioning shared by the
whole batch, a model) is kept or dropped with its frame.
Duck-typed on ``.shape`` + list indexing (torch tensors and numpy arrays both
qualify) so this module stays free of torch and ComfyUI imports and is tested
with plain pytest.
The waiting side lives here too: ``PendingAnswers`` is the thread-safe hand-off
between the HTTP route (aiohttp's loop) and the node (ComfyUI runs prompts in a
worker thread with its own event loop, so an ``asyncio.Event`` from one loop
can't be awaited from the other).
"""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, field
from typing import Any
Pick = tuple[int, int]
#: One output frame: the source frame and the image indexes it keeps.
PlanItem = tuple[int, list[int]]
MODES = ("pause", "reuse", "pass_all")
SEND_AS = ("same_shape", "per_image")
ON_TIMEOUT = ("keep_all", "stop", "keep_first")
DEFAULT_CONFIG: dict[str, Any] = {
"mode": "pause",
"send_as": "same_shape",
# Seconds; 0 waits until answered (or the run is cancelled).
"timeout": 600,
"on_timeout": "keep_all",
}
#: Upper bound for the timeout widget (a day). Guards against a typo turning
#: into a node that can never time out by accident.
MAX_TIMEOUT = 86_400
def parse_config(raw: Any) -> dict[str, Any]:
"""Decode the node's JSON config widget, falling back per key.
Unknown keys are ignored and bad values collapse to their default, so a
hand-edited or older workflow still runs (same recovery rule as the
Context Loop's ``_parse_config``).
"""
out = dict(DEFAULT_CONFIG)
if isinstance(raw, str):
try:
raw = json.loads(raw) if raw.strip() else {}
except ValueError:
raw = {}
if not isinstance(raw, dict):
return out
for key, allowed in (
("mode", MODES),
("send_as", SEND_AS),
("on_timeout", ON_TIMEOUT),
):
if raw.get(key) in allowed:
out[key] = raw[key]
timeout = raw.get("timeout")
if isinstance(timeout, (int, float)) and not isinstance(timeout, bool):
out["timeout"] = max(0, min(MAX_TIMEOUT, int(timeout)))
return out
# ------------------------------------------------------------------ picks ---
def batch_len(value: Any) -> int | None:
"""First dimension of a tensor-like, else None."""
shape = getattr(value, "shape", None)
if shape is None:
return None
try:
return int(shape[0]) if len(shape) >= 1 else None
except (TypeError, ValueError):
return None
def all_picks(batch_sizes: list[int]) -> list[Pick]:
return [(f, i) for f, b in enumerate(batch_sizes) for i in range(b)]
def normalize_picks(raw: Any, batch_sizes: list[int]) -> list[Pick]:
"""Keep the in-range ``[frame, image]`` pairs, deduplicated, in order."""
if not isinstance(raw, (list, tuple)):
return []
seen: set[Pick] = set()
for item in raw:
if not isinstance(item, (list, tuple)) or len(item) != 2:
continue
f, i = item
if isinstance(f, bool) or isinstance(i, bool):
continue
if not isinstance(f, int) or not isinstance(i, int):
continue
if 0 <= f < len(batch_sizes) and 0 <= i < batch_sizes[f]:
seen.add((f, i))
return sorted(seen)
def plan_output(picks: list[Pick], send_as: str = "same_shape") -> list[PlanItem]:
"""Group picks into output frames.
``same_shape``: one output frame per source frame that has a pick, holding
its picked images. ``per_image``: one output frame per picked image.
"""
ordered = sorted(set(picks))
if send_as == "per_image":
return [(f, [i]) for f, i in ordered]
plan: list[PlanItem] = []
for f, i in ordered:
if plan and plan[-1][0] == f:
plan[-1][1].append(i)
else:
plan.append((f, [i]))
return plan
def format_picks(picks: list[Pick]) -> str:
"""1-based ``frame:image`` list for the ``picks`` output, e.g. ``1:2, 3:1``."""
return ", ".join(f"{f + 1}:{i + 1}" for f, i in sorted(picks))
# ------------------------------------------------------------- following ---
def align(values: list[Any] | None, frames: int) -> list[Any]:
"""Line a slot's list up with the frames, ComfyUI-style.
A slot with fewer items than frames repeats its last item (that is how
ComfyUI pairs lists of different lengths); an unconnected slot is None for
every frame.
"""
if not values:
return [None] * frames
return [values[f] if f < len(values) else values[-1] for f in range(frames)]
def _take(value: Any, idx: list[int], batch: int) -> Any:
n = batch_len(value)
if n is None or n != batch or idx == list(range(batch)):
return value
return value[idx]
def _is_conditioning(value: Any) -> bool:
return (
isinstance(value, list)
and len(value) > 0
and all(
isinstance(entry, (list, tuple))
and len(entry) == 2
and batch_len(entry[0]) is not None
and isinstance(entry[1], dict)
for entry in value
)
)
def take_images(value: Any, idx: list[int], batch: int) -> Any:
"""Slice one frame's value down to the picked images, when it is batched.
Values whose batch doesn't match the frame's image batch pass through: a
batch-1 conditioning broadcast over 4 images, a single mask for the whole
batch, a string.
"""
if value is None:
return None
if isinstance(value, dict) and "samples" in value:
# LATENT: slice samples and whatever travels batched beside them.
out = dict(value)
out["samples"] = _take(value["samples"], idx, batch)
if "noise_mask" in value:
out["noise_mask"] = _take(value["noise_mask"], idx, batch)
bi = value.get("batch_index")
if isinstance(bi, list) and len(bi) == batch:
out["batch_index"] = [bi[i] for i in idx]
return out
if _is_conditioning(value):
sliced = []
for tensor, extras in value:
new_extras = {
k: (_take(v, idx, batch) if batch_len(v) is not None else v)
for k, v in extras.items()
}
sliced.append([_take(tensor, idx, batch), new_extras])
return sliced
return _take(value, idx, batch)
def follow_slot(
values: list[Any] | None,
plan: list[PlanItem],
batch_sizes: list[int],
) -> list[Any]:
"""The output list for one slot under ``plan``."""
aligned = align(values, len(batch_sizes))
return [take_images(aligned[f], idx, batch_sizes[f]) for f, idx in plan]
# ---------------------------------------------------------------- answers ---
#: Per-image edits from the picker: ``positive`` / ``negative`` replacement
#: text and ``mask``, a PNG data URL (white = masked) at any size.
Edit = dict[str, str]
#: Longest prompt edit accepted (characters) and mask data URL (bytes).
MAX_EDIT_TEXT = 20_000
MAX_MASK_URL = 16 * 1024 * 1024
MASK_PREFIX = "data:image/png;base64,"
@dataclass(frozen=True)
class Answer:
"""What the picker sent back: ``picks``, ``keep_all`` or ``stop``, plus
any per-image ``edits`` made in the picker."""
action: str
picks: tuple[Pick, ...] = ()
edits: dict[Pick, Edit] = field(default_factory=dict)
def parse_edits(raw: Any, batch_sizes: list[int]) -> dict[Pick, Edit]:
"""Validate ``{"f:i": {positive?, negative?, mask?}}``; bad entries drop."""
out: dict[Pick, Edit] = {}
if not isinstance(raw, dict):
return out
for key, entry in raw.items():
if not isinstance(key, str) or not isinstance(entry, dict):
continue
parts = key.split(":")
if len(parts) != 2 or not all(p.isdigit() for p in parts):
continue
f, i = int(parts[0]), int(parts[1])
if not (0 <= f < len(batch_sizes) and 0 <= i < batch_sizes[f]):
continue
edit: Edit = {}
for name in ("positive", "negative"):
text = entry.get(name)
if isinstance(text, str) and len(text) <= MAX_EDIT_TEXT:
edit[name] = text
mask = entry.get("mask")
if isinstance(mask, str) and mask.startswith(MASK_PREFIX) and len(mask) <= MAX_MASK_URL:
edit["mask"] = mask
if edit:
out[(f, i)] = edit
return out
def parse_answer(payload: Any, batch_sizes: list[int]) -> Answer | None:
"""Validate a picker response body; None when it is unusable."""
if not isinstance(payload, dict):
return None
action = payload.get("action")
edits = parse_edits(payload.get("edits"), batch_sizes)
if action == "keep_all":
return Answer("keep_all", tuple(all_picks(batch_sizes)), edits)
if action == "stop":
return Answer("stop")
if action == "picks":
picks = tuple(normalize_picks(payload.get("picks"), batch_sizes))
return Answer("picks", picks, {p: e for p, e in edits.items() if p in picks})
return None
def item_text_edits(
plan: list[PlanItem], edits: dict[Pick, Edit], key: str,
) -> list[str | None]:
"""Edited ``positive`` / ``negative`` text per output item, or None.
An item with several images (Same shape) has one prompt, so the first
edited image in it decides (the picker writes the same text to all of a
frame's images in that mode).
"""
out: list[str | None] = []
for f, idx in plan:
text = next((edits[(f, i)][key] for i in idx if key in edits.get((f, i), {})), None)
out.append(text)
return out
def item_mask_edits(plan: list[PlanItem], edits: dict[Pick, Edit]) -> list[list[str] | None]:
"""Per output item, the painted mask URL of each image ("" when that image
has none), or None when no image of the item was painted."""
out: list[list[str] | None] = []
for f, idx in plan:
urls = [edits.get((f, i), {}).get("mask", "") for i in idx]
out.append(urls if any(urls) else None)
return out
def resolve_timeout(on_timeout: str, batch_sizes: list[int]) -> Answer:
if on_timeout == "stop":
return Answer("stop")
if on_timeout == "keep_first" and batch_sizes:
return Answer("picks", ((0, 0),))
return Answer("keep_all", tuple(all_picks(batch_sizes)))
class PendingAnswers:
"""Thread-safe registry of nodes waiting for a pick.
``open`` registers a request (and keeps its payload so a reloaded page can
show it again), ``answer`` is called from the HTTP route, ``take`` is polled
by the waiting node, ``close`` drops the request whatever happened.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._requests: dict[str, dict[str, Any]] = {}
self._answers: dict[str, Any] = {}
def open(self, token: str, request: dict[str, Any]) -> None:
with self._lock:
self._requests[token] = request
self._answers.pop(token, None)
def answer(self, token: str, payload: Any) -> bool:
"""Store a response; False when nothing is waiting on ``token``."""
with self._lock:
if token not in self._requests:
return False
self._answers[token] = payload
return True
def take(self, token: str) -> Any:
with self._lock:
return self._answers.pop(token, None)
def close(self, token: str) -> None:
with self._lock:
self._requests.pop(token, None)
self._answers.pop(token, None)
def pending(self) -> list[dict[str, Any]]:
with self._lock:
return list(self._requests.values())
#: Process-wide registry shared by the node and the route.
PENDING = PendingAnswers()
#: Last picks per node (``unique_id``), for the "Reuse last picks" mode.
#: In memory only: a restart forgets them and the node asks again.
LAST_PICKS: dict[str, list[Pick]] = {}
def reusable_picks(stored: list[Pick] | None, batch_sizes: list[int]) -> list[Pick] | None:
"""Stored picks when they still fit the incoming frames, else None."""
if not stored:
return None
picks = normalize_picks([list(p) for p in stored], batch_sizes)
return picks if len(picks) == len(stored) else None
+67 -7
View File
@@ -359,6 +359,27 @@ def _bundle_exists(conn: sqlite3.Connection, bid: str) -> bool:
return row is not None
def _follow_category(
conn: sqlite3.Connection, entity: dict[str, Any], cat_map: dict[str, str],
) -> dict[str, Any]:
"""Point an entity's ``category_id`` at where its category landed.
A category merged into an existing one by name keeps the existing id,
so members carrying the incoming id follow it. An id that names no
category at all (not imported, not in the library) becomes ``None``
rather than failing the whole commit on the foreign key.
"""
cid = entity.get("category_id")
if not isinstance(cid, str) or not cid:
return entity
target = cat_map.get(cid, cid)
if target == cid and _category_id_exists(conn, cid):
return entity
out = dict(entity)
out["category_id"] = target if _category_id_exists(conn, target) else None
return out
def _category_id_exists(conn: sqlite3.Connection, cid: str) -> bool:
row = conn.execute(
"SELECT 1 FROM module_categories WHERE id = ?;", (cid,),
@@ -431,14 +452,16 @@ def _insert_template(
conn.execute(
"INSERT INTO templates("
"id, name, description, category_id, tags, "
"is_favorite, template_string, created_at, updated_at"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?);",
"is_favorite, template_string, negative_template, "
"created_at, updated_at"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
(
tid, entity["name"], entity.get("description", ""),
entity.get("category_id"),
json.dumps(entity.get("tags") or []),
int(entity.get("is_favorite", False)),
entity.get("template_string", ""),
_negative_template_of(entity),
entity.get("created_at", now),
entity.get("updated_at", now),
),
@@ -446,20 +469,29 @@ def _insert_template(
return tid
def _negative_template_of(entity: dict[str, Any]) -> str | None:
"""Migration 019's optional column. Absent (a pack exported before
negatives existed) or not a string = NULL."""
neg = entity.get("negative_template")
return neg if isinstance(neg, str) else None
def _update_template(
conn: sqlite3.Connection, tid: str, content: dict[str, Any],
) -> None:
"""UPDATE the templates row identified by `tid` to mirror `content`.
Stamps `updated_at`; no `version` bump (templates have no version
column).
column). A pack without `negative_template` keeps the row's existing
negative (COALESCE) rather than wiping it.
"""
_require_entity_fields("template", "replace", content, ("name",))
now = now_iso()
conn.execute(
"UPDATE templates SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, template_string = ?, updated_at = ? "
"is_favorite = ?, template_string = ?, "
"negative_template = COALESCE(?, negative_template), updated_at = ? "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
@@ -467,6 +499,7 @@ def _update_template(
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
content.get("template_string", ""),
_negative_template_of(content),
now, tid,
),
)
@@ -492,11 +525,19 @@ def _fetch_template_row(
"tags": json.loads(row["tags"]),
"is_favorite": bool(row["is_favorite"]),
"template_string": row["template_string"],
"negative_template": _row_negative_template(row),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _row_negative_template(row: sqlite3.Row) -> str | None:
try:
return row["negative_template"]
except (IndexError, KeyError):
return None
def commit_import(
conn: sqlite3.Connection, payload: dict[str, Any],
) -> dict[str, Any]:
@@ -511,6 +552,16 @@ def commit_import(
``with conn:`` block, and the import_undo row is NOT inserted.
"""
adds = list(payload.get("adds") or [])
# Categories go in first: modules, bundles and templates point at them
# by id (a foreign key), so a category listed after its members would
# fail the insert.
adds = (
[op for op in adds if isinstance(op, dict) and op.get("kind") == "category"]
+ [op for op in adds if not (isinstance(op, dict) and op.get("kind") == "category")]
)
# Incoming category id -> the id it landed on (its own, or the existing
# category it merged into by name).
cat_map: dict[str, str] = {}
replaces = list(payload.get("replaces") or [])
renames = list(payload.get("renames") or [])
@@ -546,7 +597,9 @@ def commit_import(
(name,),
).fetchone()
if existing is not None:
# Skip — nothing to undo.
# Skip — nothing to undo. Members follow the merge.
if entity.get("id"):
cat_map[entity["id"]] = existing[0]
continue
cid = entity.get("id") or name.lower()
if _category_id_exists(conn, cid):
@@ -565,8 +618,11 @@ def commit_import(
),
)
imported_records.append({"kind": "category", "id": cid})
if entity.get("id"):
cat_map[entity["id"]] = cid
elif kind == "bundle":
entity = _follow_category(conn, entity, cat_map)
_require_entity_fields(
"bundle", "add", entity, ("id", "name"),
)
@@ -579,6 +635,7 @@ def commit_import(
imported_records.append({"kind": "bundle", "id": bid})
elif kind == "template":
entity = _follow_category(conn, entity, cat_map)
_require_entity_fields(
"template", "add", entity, ("id", "name"),
)
@@ -591,6 +648,7 @@ def commit_import(
imported_records.append({"kind": "template", "id": tid})
elif _is_module_kind(kind or ""):
entity = _follow_category(conn, entity, cat_map)
_require_entity_fields(
kind, "add", entity, ("id", "name"), # type: ignore[arg-type]
)
@@ -612,7 +670,7 @@ def commit_import(
for op in replaces:
kind = op.get("kind")
rid = op.get("id")
new_content = op.get("new_content") or {}
new_content = _follow_category(conn, op.get("new_content") or {}, cat_map)
if not rid:
raise _ImporterContractError(
f"{kind} replace missing 'id' field",
@@ -653,7 +711,7 @@ def commit_import(
kind = op.get("kind")
old_id = op.get("old_id")
new_id = op.get("new_id")
content = op.get("content") or {}
content = _follow_category(conn, op.get("content") or {}, cat_map)
if not new_id:
raise _ImporterContractError(
f"{kind} rename missing 'new_id' field",
@@ -816,6 +874,7 @@ def undo_import(
"UPDATE templates SET "
"name = ?, description = ?, category_id = ?, "
"tags = ?, is_favorite = ?, template_string = ?, "
"negative_template = ?, "
"created_at = ?, updated_at = ? "
"WHERE id = ?;",
(
@@ -824,6 +883,7 @@ def undo_import(
json.dumps(row["tags"]),
int(row["is_favorite"]),
row["template_string"],
row.get("negative_template"),
row["created_at"], row["updated_at"], rid,
),
)
+24 -5
View File
@@ -16,16 +16,35 @@ CURRENT_SCHEMA_VERSION = 2
# Highest schema version this runtime can natively READ + WRITE — distinct
# from CURRENT_SCHEMA_VERSION (the migration-chain head, which stays 2 because
# v2->v3/v3->v4 are no-ops). v3 is the SP2b text-grammar bump (shape-identical
# to v2) and v4 is the additive `target_select` constraint reach; both are
# handled natively, so a payload at CURRENT < v <= MAX_KNOWN installs AS-IS
# with no migration. Mirror of TS `MAX_KNOWN_SCHEMA_VERSION` in
# v2->v3 through v6->v7 are no-ops). v3 is the SP2b text-grammar bump (shape-
# identical to v2), v4 is the additive `target_select` constraint reach, v5
# is the additive `accepts` tag-axis kind, v6 the constraint `only` rule and
# v7 derivation AND/OR condition groups plus the `is_empty`/`is_not_empty` ops,
# v8 send-to-negative (`negative` text + the derivation `negative` mode);
# all are handled natively, so a
# payload at CURRENT < v <= MAX_KNOWN installs AS-IS with no migration.
# Mirror of TS `MAX_KNOWN_SCHEMA_VERSION` in
# `src/manager/import-export/migrations.ts`.
#
# MAINTENANCE CONTRACT: bump this whenever the TS `schemaVersionForPayload()`
# learns to stamp a new (higher) version — otherwise this commit-side
# re-validate will reject the very shapes the runtime just learned to produce.
MAX_KNOWN_SCHEMA_VERSION = 4
MAX_KNOWN_SCHEMA_VERSION = 9
# Feature stamps (mirror of the TS constants beside MAX_KNOWN). Each is the
# catalog version a payload needs once it uses that feature; see
# `engine/migrations/stamping.py:schema_version_for_payload`.
SP2B_SCHEMA_VERSION = 3 # range count / `~` flag in a `{N$$…}` multi-pick
SP3_REACH_SCHEMA_VERSION = 4 # non-default constraint `target_select`
TAG_AXES_SCHEMA_VERSION = 5 # wildcard `tag_group_kinds` with an `accepts` group
CONSTRAINT_ONLY_SCHEMA_VERSION = 6 # constraint rule with mode `only`
# derivation condition group (AND / OR) or an `is_empty` / `is_not_empty` test
DERIVATION_CONDITIONS_SCHEMA_VERSION = 7
# send-to-negative: a non-empty `negative` on a wildcard option, fixed value or
# combine, or a derivation action with mode `negative`
NEGATIVES_SCHEMA_VERSION = 8
# wildcard fallback option: an option flagged `fallback: true`
FALLBACK_SCHEMA_VERSION = 9
_CHAIN = {
0: migrate_v0_to_v1,
+241
View File
@@ -0,0 +1,241 @@
"""Content-driven ``schema_version`` stamping. Mirror of TS
``schemaVersionForPayload`` in ``src/manager/import-export/single-row-publish.ts``.
A payload is stamped with the LOWEST catalog version its features need, so
an older consumer can still install everything that doesn't use a newer
feature:
- ``FALLBACK_SCHEMA_VERSION`` (9): a wildcard option is flagged
``fallback: true`` (also used when nothing else is left to pick).
- ``NEGATIVES_SCHEMA_VERSION`` (8): a wildcard option, fixed value or
combine carries a non-empty ``negative``, a derivation action uses the
``negative`` ("Add to negative") mode, a derivation branch runs more than
one action (``extra_actions``), or text reads a variable's negatives
(``$name.neg``).
- ``DERIVATION_CONDITIONS_SCHEMA_VERSION`` (7): a derivation branch groups
tests with AND / OR, or a test uses ``is_empty`` / ``is_not_empty``.
- ``CONSTRAINT_ONLY_SCHEMA_VERSION`` (6): a constraint matrix cell,
exception or instance mode override uses the ``only`` rule.
- ``TAG_AXES_SCHEMA_VERSION`` (5): a wildcard ``tag_group_kinds`` map marks
any group ``accepts``.
- ``SP3_REACH_SCHEMA_VERSION`` (4): a constraint carries a non-default
``target_select`` reach selector.
- ``SP2B_SCHEMA_VERSION`` (3): the payload text uses a range count or the
``~`` independent flag in a ``{N$$…}`` multi-pick.
- ``CURRENT_SCHEMA_VERSION`` (2) otherwise.
Each scan walks the whole structure (bundle children, instance overrides,
every export bucket), so the marker is found wherever it lives. Keep the two
sides in step: a new feature stamp lands in both files in the same PR.
"""
from __future__ import annotations
import json
import re
from typing import Any
from engine.migrations import (
CONSTRAINT_ONLY_SCHEMA_VERSION,
CURRENT_SCHEMA_VERSION,
DERIVATION_CONDITIONS_SCHEMA_VERSION,
FALLBACK_SCHEMA_VERSION,
NEGATIVES_SCHEMA_VERSION,
SP2B_SCHEMA_VERSION,
SP3_REACH_SCHEMA_VERSION,
TAG_AXES_SCHEMA_VERSION,
)
# SP2b nested-multi-pick marker: a `{N$$…}` whose count is a range (`N-M`)
# or carries the `~` flag. A plain fixed-count `{N$$…}` predates SP2b.
_SP2B_MARKER_RE = re.compile(r"\{\d+(?:-\d+~?|~)\$\$")
# `$name.neg`, `$name.K.neg` (and `$name.neg.K`): reads a variable's negatives.
_NEG_ACCESSOR_RE = re.compile(
r"\$[A-Za-z_][A-Za-z0-9_]*(?:\.\d+)?\.neg(?![A-Za-z0-9_])"
)
def _is_number(value: Any) -> bool:
# JSON booleans come back as Python bools, which are ints; TS
# `typeof x === "number"` is false for them.
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _is_non_default_target_select(sel: Any) -> bool:
"""True when ``sel`` narrows reach. Only an absent selector or a bare
``{"mode": "all"}`` is the default; ``{"mode": "pick", "picks": []}``
still counts because a pre-SP3 consumer can't parse the mode at all."""
if not isinstance(sel, dict):
return False
mode = sel.get("mode")
if isinstance(mode, str) and mode != "all":
return True
if _is_number(sel.get("count")):
return True
picks = sel.get("picks")
return isinstance(picks, list) and len(picks) > 0
def uses_target_select_reach(node: Any) -> bool:
"""Any ``target_select`` at any depth holding a non-default selector."""
if isinstance(node, list):
return any(uses_target_select_reach(child) for child in node)
if not isinstance(node, dict):
return False
if _is_non_default_target_select(node.get("target_select")):
return True
return any(
isinstance(value, (dict, list)) and uses_target_select_reach(value)
for value in node.values()
)
def uses_accepts_tag_axis(node: Any) -> bool:
"""Any ``tag_group_kinds`` map at any depth marking a group ``accepts``."""
if isinstance(node, list):
return any(uses_accepts_tag_axis(child) for child in node)
if not isinstance(node, dict):
return False
kinds = node.get("tag_group_kinds")
if isinstance(kinds, dict) and any(k == "accepts" for k in kinds.values()):
return True
return any(
isinstance(value, (dict, list)) and uses_accepts_tag_axis(value)
for value in node.values()
)
_EXCEPTION_LIST_KEYS = ("exceptions", "extra_exceptions")
_MODE_OVERRIDE_KEYS = ("cell_mode_overrides", "exception_mode_overrides")
def _matrix_uses_only(matrix: Any) -> bool:
if not isinstance(matrix, dict):
return False
return any(
isinstance(row, dict) and any(
isinstance(cell, dict) and cell.get("mode") == "only"
for cell in row.values()
)
for row in matrix.values()
)
def uses_constraint_only_rule(node: Any) -> bool:
"""Any constraint rule at any depth whose mode is ``only``: a library
matrix cell or exception, an instance extra exception, or an instance mode
override (a string map)."""
if isinstance(node, list):
return any(uses_constraint_only_rule(child) for child in node)
if not isinstance(node, dict):
return False
if _matrix_uses_only(node.get("matrix")):
return True
for key in _EXCEPTION_LIST_KEYS:
excs = node.get(key)
if isinstance(excs, list) and any(
isinstance(e, dict) and e.get("mode") == "only" for e in excs
):
return True
for key in _MODE_OVERRIDE_KEYS:
overrides = node.get(key)
if isinstance(overrides, dict) and any(
m == "only" for m in overrides.values()
):
return True
return any(
isinstance(value, (dict, list)) and uses_constraint_only_rule(value)
for value in node.values()
)
_V7_CONDITION_OPS = ("is_empty", "is_not_empty")
def _is_v7_condition(cond: Any) -> bool:
if not isinstance(cond, dict):
return False
if "conditions" in cond:
return True
return cond.get("op") in _V7_CONDITION_OPS
def uses_derivation_conditions(node: Any) -> bool:
"""Any derivation branch at any depth whose ``condition`` is an AND / OR
group or a test using ``is_empty`` / ``is_not_empty``."""
if isinstance(node, list):
return any(uses_derivation_conditions(child) for child in node)
if not isinstance(node, dict):
return False
branches = node.get("branches")
if isinstance(branches, list) and any(
isinstance(b, dict) and _is_v7_condition(b.get("condition"))
for b in branches
):
return True
return any(
isinstance(value, (dict, list)) and uses_derivation_conditions(value)
for value in node.values()
)
def uses_negatives(node: Any) -> bool:
"""Any non-empty ``negative`` string, or a derivation action whose mode is
``negative``, at any depth. An empty negative is stored as absent and
never needs v8."""
if isinstance(node, list):
return any(uses_negatives(child) for child in node)
if not isinstance(node, dict):
return False
neg = node.get("negative")
if isinstance(neg, str) and neg.strip():
return True
if node.get("mode") in ("negative", "negative_replace") and "target_var" in node:
return True
extra = node.get("extra_actions")
if isinstance(extra, list) and extra:
return True
return any(
(isinstance(value, (dict, list)) and uses_negatives(value))
or (isinstance(value, str) and _NEG_ACCESSOR_RE.search(value) is not None)
for value in node.values()
)
def uses_fallback_option(node: Any) -> bool:
"""Any wildcard option flagged ``fallback: true`` at any depth (library
payload, bundle child or a node's own copy)."""
if isinstance(node, list):
return any(uses_fallback_option(child) for child in node)
if not isinstance(node, dict):
return False
if node.get("fallback") is True and "value" in node:
return True
return any(
isinstance(value, (dict, list)) and uses_fallback_option(value)
for value in node.values()
)
def uses_sp2b_grammar(node: Any) -> bool:
"""Range-count or ``~`` multi-pick anywhere in the serialised payload."""
text = json.dumps(node, ensure_ascii=False, default=str)
return _SP2B_MARKER_RE.search(text) is not None
def schema_version_for_payload(payload: Any) -> int:
"""The lowest catalog version that covers every feature in ``payload``."""
if uses_fallback_option(payload):
return FALLBACK_SCHEMA_VERSION
if uses_negatives(payload):
return NEGATIVES_SCHEMA_VERSION
if uses_derivation_conditions(payload):
return DERIVATION_CONDITIONS_SCHEMA_VERSION
if uses_constraint_only_rule(payload):
return CONSTRAINT_ONLY_SCHEMA_VERSION
if uses_accepts_tag_axis(payload):
return TAG_AXES_SCHEMA_VERSION
if uses_target_select_reach(payload):
return SP3_REACH_SCHEMA_VERSION
if uses_sp2b_grammar(payload):
return SP2B_SCHEMA_VERSION
return CURRENT_SCHEMA_VERSION
+248
View File
@@ -0,0 +1,248 @@
"""Model detection for WP_ModelInfo — pure string logic, no ComfyUI imports.
The node hands this module two facts it read off the graph: the loaded
model's config class name (``SDXL``, ``Flux``, …) and the checkpoint file
name. From those it derives the three variables the node writes:
* ``model_family`` — the architecture, from the config class name.
* ``model_variant`` — the fine-tune lineage (pony, illustrious, …), from the
file name. Pony and Illustrious are both SDXL by architecture, so the file
name is the only place that difference shows.
* ``model_name`` — the file name without folders or extension.
Variant rules are mirrored in TS at ``src/extension/model-info.ts`` (static
preview on the canvas) via the shared ``tests/fixtures/model-variant-corpus.json``.
Keep the two in step.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import Any
#: Variables the node writes, in display order.
FAMILY_VAR = "model_family"
VARIANT_VAR = "model_variant"
NAME_VAR = "model_name"
MODEL_VARS = (FAMILY_VAR, VARIANT_VAR, NAME_VAR)
#: Shipped variant rules, as the node's rule rows: first match wins,
#: case-insensitive, matched anywhere in the file name. NoobAI sits above
#: Illustrious because NoobAI files often mention both.
DEFAULT_VARIANT_RULES: tuple[dict[str, str], ...] = (
{"variant": "noobai", "pattern": "noob"},
{"variant": "pony", "pattern": "pony|pdxl"},
{"variant": "illustrious", "pattern": "illustrious|ilxl"},
{"variant": "animagine", "pattern": "animagine"},
)
# Config class name (``model.model.model_config``'s class) → family. Checked
# as prefixes, longest first, so ``SDXLRefiner`` lands before ``SDXL``.
_FAMILY_PREFIXES: tuple[tuple[str, str], ...] = (
("SDXLRefiner", "sdxl_refiner"),
("SDXL", "sdxl"),
("SSD1B", "sdxl"),
("Segmind_Vega", "sdxl"),
("KOALA", "sdxl"),
("SD15", "sd15"),
("SD20", "sd2"),
("SD21", "sd2"),
("SD3", "sd3"),
("Stable_Cascade", "stable_cascade"),
("FluxSchnell", "flux"),
("Flux", "flux"),
("Chroma", "chroma"),
("AuraFlow", "auraflow"),
("PixArt", "pixart"),
("HunyuanDiT", "hunyuan_dit"),
("HunyuanVideo", "hunyuan_video"),
("HiDream", "hidream"),
("Lumina2", "lumina2"),
("QwenImage", "qwen_image"),
("WAN", "wan"),
("LTXV", "ltxv"),
("Cosmos", "cosmos"),
)
def family_from_config_name(class_name: str) -> str:
"""Map a model config class name to a lowercase family id.
Unknown classes fall back to their own name in snake case, so a model
ComfyUI adds after this list was written still gets a stable value.
"""
name = (class_name or "").strip()
if not name:
return ""
for prefix, family in sorted(_FAMILY_PREFIXES, key=lambda p: -len(p[0])):
if name.startswith(prefix):
return family
snake = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name)
return re.sub(r"[^a-z0-9]+", "_", snake.lower()).strip("_")
def model_stem(path: str) -> str:
"""``SDXL\\pony\\ponyDiffusionV6XL.safetensors`` → ``ponyDiffusionV6XL``."""
base = re.split(r"[\\/]", (path or "").strip())[-1]
dot = base.rfind(".")
return base[:dot] if dot > 0 else base
@dataclass(frozen=True)
class VariantRule:
variant: str
pattern: str
regex: re.Pattern[str]
def compile_variant_rules(rows: Any) -> tuple[list[VariantRule], list[str]]:
"""Compile the node's rule rows. Returns ``(rules, problems)``.
A row with neither field filled is an unfinished row and is skipped
quietly. A row missing one field, or with a pattern that does not
compile, is reported in ``problems`` and skipped, so one typo never
stops the other rules from working.
"""
rules: list[VariantRule] = []
problems: list[str] = []
for row in rows if isinstance(rows, (list, tuple)) else ():
if not isinstance(row, dict):
continue
variant = str(row.get("variant") or "").strip()
pattern = str(row.get("pattern") or "").strip()
if not variant and not pattern:
continue
if not variant or not pattern:
problems.append(
f"rule {variant or pattern!r} needs both a variant and a pattern",
)
continue
try:
regex = re.compile(pattern, re.IGNORECASE)
except re.error as exc:
problems.append(f"bad pattern for {variant!r}: {exc}")
continue
rules.append(VariantRule(variant, pattern, regex))
return rules, problems
@dataclass(frozen=True)
class ModelInfoConfig:
"""The node's widget state. Empty pins mean "detect it"."""
rules: tuple[dict[str, str], ...]
family: str = ""
variant: str = ""
name: str = ""
#: Short keys (``family`` / ``variant`` / ``name``) flagged internal: the
#: variable still reaches later rules, but the Assembler leaves it out.
#: All three are internal unless the widget says ``false``.
internal: frozenset[str] = frozenset(("family", "variant", "name"))
def parse_config(raw: Any) -> ModelInfoConfig:
"""Read the ``wp_model_info`` widget JSON. Anything unreadable, or a
config without a ``rules`` list, gets the shipped rules."""
data: Any = raw
if isinstance(raw, str):
try:
data = json.loads(raw) if raw.strip() else {}
except ValueError:
data = {}
if not isinstance(data, dict):
data = {}
rows = data.get("rules")
rules = (
tuple(r for r in rows if isinstance(r, dict))
if isinstance(rows, list) else DEFAULT_VARIANT_RULES
)
def pin(key: str) -> str:
v = data.get(key)
return v.strip() if isinstance(v, str) else ""
flags = data.get("internal")
internal = frozenset(
k for k in ("family", "variant", "name")
if not (isinstance(flags, dict) and flags.get(k) is False)
)
return ModelInfoConfig(rules, pin("family"), pin("variant"), pin("name"), internal)
def detect_variant(name: str, rules: list[VariantRule]) -> str:
"""The first rule whose pattern occurs in ``name``, else ``""``."""
if not name:
return ""
for rule in rules:
if rule.regex.search(name):
return rule.variant
return ""
# Input names a loader node keeps its file name under.
_NAME_INPUTS = ("ckpt_name", "unet_name", "model_name", "model_path")
# Inputs a pass-through node (LoRA loader, sampling patch, …) takes the model on.
_MODEL_INPUTS = ("model", "MODEL")
# Inputs a primitive node holding a converted widget keeps its value under.
_VALUE_INPUTS = ("value", "string", "text", "STRING")
def _is_link(v: Any) -> bool:
return isinstance(v, (list, tuple)) and len(v) == 2 and isinstance(v[1], int)
def _string_behind(prompt: dict, value: Any, depth: int = 0) -> str:
"""A widget value, following it through a primitive node when the widget
was converted to an input."""
if isinstance(value, str):
return value
if not _is_link(value) or depth > 8:
return ""
node = prompt.get(str(value[0]))
inputs = node.get("inputs") if isinstance(node, dict) else None
if not isinstance(inputs, dict):
return ""
for key in _NAME_INPUTS + _VALUE_INPUTS:
if key in inputs:
found = _string_behind(prompt, inputs[key], depth + 1)
if found:
return found
return ""
def find_checkpoint_name(
prompt: Any, node_id: Any, input_name: str = "model",
) -> str:
"""Follow ``node_id``'s ``input_name`` link back to the node that loaded
the model and return its file name, or ``""``.
``prompt`` is ComfyUI's API prompt (``{id: {class_type, inputs}}``, links
as ``[origin_id, slot]``). Pass-through nodes — LoRA loaders, sampling
patches — are stepped over via their own ``model`` input.
"""
if not isinstance(prompt, dict) or node_id is None:
return ""
node = prompt.get(str(node_id))
inputs = node.get("inputs") if isinstance(node, dict) else None
link = inputs.get(input_name) if isinstance(inputs, dict) else None
seen: set[str] = set()
while _is_link(link):
origin_id = str(link[0])
if origin_id in seen or len(seen) > 64:
return ""
seen.add(origin_id)
origin = prompt.get(origin_id)
o_inputs = origin.get("inputs") if isinstance(origin, dict) else None
if not isinstance(o_inputs, dict):
return ""
for key in _NAME_INPUTS:
if key in o_inputs:
name = _string_behind(prompt, o_inputs[key])
if name:
return name
link = next(
(o_inputs[k] for k in _MODEL_INPUTS if _is_link(o_inputs.get(k))),
None,
)
return ""
+107
View File
@@ -47,6 +47,101 @@ class _RuntimeResolveContext:
# occurrence matches on. None outside any carrier option.
_carrier_uid: str | None = None
_carrier_option_id: str | None = None
# Rolled `accepts`-axis choices, keyed by VARIABLE BINDING (the namespace
# `$outfit.SHOES` addresses) rather than module uuid like `_picks`. A
# projection of `ctx["__wp_axes__"]`, built once here so the resolver stays
# a pure consumer with no ctx dependency.
_axes: dict[str, Any] = field(default_factory=dict)
# Accepts axes each binding's wildcard declares (`ctx["__wp_axis_decl__"]`),
# so an empty axis read can say whether the axis is missing or the pick
# simply carried no tag on it.
_axis_decl: dict[str, Any] = field(default_factory=dict)
# Opt-in record of every nested `@{uuid}` pick (`ctx["__wp_ref_log__"]`,
# a list the caller seeds, e.g. the Test Runner). None when the caller
# did not ask, so a canvas run records nothing. `_ref_owner` is the
# stack uid of the module whose resolve this frame belongs to.
_ref_log: list[dict[str, Any]] | None = None
_ref_owner: str | None = None
# Send-to-negative collectors (engine/negatives.py `collecting`). None =
# not collecting. `_reads` gets one (name, index) per `$var` read the
# text rendered; `_ref_negs` gets the `negative` of every option a nested
# `@{ref}` picked. A quiet (negative-side) resolve leaves both None.
_reads: list[tuple[str, Any]] | None = None
_ref_negs: list[str] | None = None
# The run's negatives table (`ctx["__wp_negatives__"]`), by reference so
# a `$name.neg` read sees negatives filed earlier in the same module.
_negs: dict[str, Any] | None = None
def get_negative(self, name: str, index: int | None) -> str | None:
"""`name`'s negatives as one line (tag-deduped), or None when it has
none. `index` narrows to pick K's entries plus the whole-value ones,
like `$name.K` carries."""
from engine import negatives # noqa: PLC0415 - import cycle
rows = self._negs.get(name) if isinstance(self._negs, dict) else None
if not isinstance(rows, list):
return None
texts = [
str(e.get("text", "")) for e in rows
if isinstance(e, dict)
and (index is None or e.get("pick") is None or e.get("pick") == index)
]
joined = negatives.join_unique(texts)
return joined or None
def note_var_read(self, name: str, index: Any) -> None:
if self._reads is not None:
self._reads.append((name, index))
def note_ref_negative(self, option: dict[str, Any]) -> None:
if self._ref_negs is None:
return
neg = option.get("negative")
if isinstance(neg, str) and neg.strip():
self._ref_negs.append(neg.strip())
def log_ref(self, entry: dict[str, Any]) -> dict[str, Any] | None:
"""Append one nested-ref pick to the ref log, tagged with the
owning module's uid, and return the stored dict (so the resolver
can fill in the resolved text after recursing). No-op → None when
no log was requested."""
if self._ref_log is None:
return None
row = {"owner": self._ref_owner, **entry}
self._ref_log.append(row)
return row
def get_axis(self, name: str, axis: str) -> Any:
"""Rolled axis choice(s) for a binding, or None when it has none.
Returns a plain string for a single-pick source and a list (one entry
per pick, in pick order) for a multi-select one, mirroring the shape of
the variable itself. Shaping and the empty/warn decision belong to the
resolver; this only fetches.
A binding that rolled no axes at all, and a binding that rolled some
but not THIS one, both yield None — the caller cannot act differently
on those two, and collapsing them keeps the read a single lookup.
"""
entry = self._axes.get(name)
if isinstance(entry, list):
# Positional: one slot per pick, None where that pick's option
# carries no tag on this axis. Compacting the gaps away shifted
# every later pick down, so `$outfit.1.SHOES` read pick 2's shoe
# beside `$outfit.1`.
got = [e.get(axis) if isinstance(e, dict) else None for e in entry]
return got if any(g is not None for g in got) else None
if isinstance(entry, dict):
return entry.get(axis)
return None
def declared_axes(self, name: str) -> list[str] | None:
"""Accepts axes the wildcard bound to `name` declares, or None when
that is unknown (nothing recorded, e.g. a ctx from before this table
existed or a binding no wildcard wrote)."""
got = self._axis_decl.get(name)
return [str(a) for a in got] if isinstance(got, list) else None
def get_var(self, name: str) -> str | None:
# SP2a: return the raw stored value (may be a ListVar from a
@@ -132,7 +227,11 @@ def build_resolve_ctx(
"""
constraints = ctx.get("__wp_constraints__")
picks = ctx.get("__wp_picks__")
axes = ctx.get("__wp_axes__")
axis_decl = ctx.get("__wp_axis_decl__")
hits = ctx.setdefault("__wp_constraint_hits__", {})
ref_log = ctx.get("__wp_ref_log__")
negs = ctx.get("__wp_negatives__")
return _RuntimeResolveContext( # type: ignore[return-value]
rng=ctx["__wp_rng__"],
max_ref_depth=int(ctx.get("__wp_max_ref_depth__", 8)),
@@ -153,6 +252,14 @@ def build_resolve_ctx(
# resolver's increments stick in ctx + share one counter with
# the direct path for first/next coverage.
_hits=hits if isinstance(hits, dict) else {},
# Same treatment as `_vars`: a projection built at frame construction,
# so `get_axis` is a dict lookup and the resolver never reaches into
# ctx.
_axes=axes if isinstance(axes, dict) else {},
_axis_decl=axis_decl if isinstance(axis_decl, dict) else {},
_ref_log=ref_log if isinstance(ref_log, list) else None,
_ref_owner=ctx.get("__wp_current_module_uid__"),
_negs=negs if isinstance(negs, dict) else None,
)
+165 -15
View File
@@ -3,14 +3,53 @@
One multiply operator at three levels: across an option's matching matrix
cells (multi-tag), across source picks (multi-pick), and — by the caller —
across constraints. `exclude` (factor 0) is the absorbing element.
**One exception, added 2026-08 (tag axes).** Tags belonging to a source group
the wildcard declared `accepts` are ALTERNATIVES the option offers, not
statements that are all true at once. Those fold with `max`, so one sibling's
`exclude` cannot speak for the rest. Everything else still multiplies, and the
axis path is unreachable unless the caller passes `axis_kinds` — so a payload
that predates the feature folds byte-identically.
**`only` (2026-09, linked picks).** A rule that turns its row into an
allow-list: when the source fires, every target WITHOUT a rule of its own in
that row is excluded. On an exception it gates by literal value — any target
value with no exception for that source value drops out. In a matrix row it
gates by tag — an option carrying no tag that has a rule in the row drops out.
The `only` rule itself weighs 1.0 (like `allow`), and a target that has any
other rule in the row keeps that rule, so `only` shuts out the unmentioned and
leaves the mentioned alone. In a matrix row an `allow` cell does NOT count as
a mention: the editors show `allow` as the empty "neutral" state, so counting
it would let an invisible cell through. An `allow` exception is a visible row
in the exceptions list, so it does count. A payload with no `only` rule never reaches either
gate, so it folds byte-identically.
"""
from __future__ import annotations
from typing import Any
EXCLUDE = object() # sentinel: option drops out of the pool
def _apply_rule(rule: dict[str, Any]):
class _ExcludeSentinel:
"""Absorbing zero: the option drops out of the pool entirely.
A distinct class rather than a bare ``object()`` so a type checker can
narrow ``float | _ExcludeSentinel`` after an isinstance test. With the bare
sentinel every ``factor *= r`` was unprovable, because ``is EXCLUDE`` does
not narrow an ``object``.
"""
__slots__ = ()
def __repr__(self) -> str: # pragma: no cover - debug aid only
return "EXCLUDE"
EXCLUDE = _ExcludeSentinel() # sentinel: option drops out of the pool
_Factor = float | _ExcludeSentinel
def _apply_rule(rule: dict[str, Any]) -> _Factor:
mode = rule.get("mode")
if mode == "exclude":
return EXCLUDE
@@ -19,10 +58,87 @@ def _apply_rule(rule: dict[str, Any]):
return max(0.0, float(rule.get("factor", 1.0)))
except (TypeError, ValueError):
return 1.0
return 1.0 # allow / unknown -> no weight change
return 1.0 # allow / only / unknown -> no weight change
def combine_constraint_factor(source_picks, option, matrix, exceptions):
def _row_has_only(row: dict[str, Any]) -> bool:
return any(isinstance(r, dict) and r.get("mode") == "only" for r in row.values())
def _cell_factor(
matrix: dict[str, Any],
source_tag: str,
opt_tags: list[str],
target_axes: dict[str, list[str]] | None = None,
) -> _Factor:
"""Fold one source tag against every tag on the option.
Classify target tags fold with PRODUCT — an option sitting on several rows
of the same source tag is genuinely several separate statements about it.
Tags belonging to a target ``accepts`` axis (``target_axes``: group →
members) fold with MAX instead: the option OFFERS those as ALTERNATIVES, so
it stays viable when the source allows ANY of them (the mirror of the
source-side accepts fold — the target's own roll is then restricted to the
allowed members by the caller). A no-rule target tag is neutral (1.0).
EXCLUDE is absorbing for a classify tag and for a fully-excluded axis; the
``target_axes`` path is unreachable unless the caller passes it, so a legacy
call folds byte-identically.
"""
row = matrix.get(source_tag)
if not isinstance(row, dict):
return 1.0
# `only` makes the row an allow-list: an option carrying no tag that the
# row mentions is shut out. Inside an accepts axis an unmentioned member
# then offers nothing (0), rather than the usual neutral 1.0. A neutral
# (`allow`) cell is not a mention — see the module docstring.
has_only = _row_has_only(row)
if has_only:
row = {
t: r for t, r in row.items()
if isinstance(r, dict) and r.get("mode") != "allow"
}
if not any(t in row for t in opt_tags):
return EXCLUDE
unruled = 0.0 if has_only else 1.0
target_axes = target_axes or {}
axis_of = {t: ax for ax, members in target_axes.items() for t in (members or [])}
factor = 1.0
axis_members: dict[str, list[str]] = {}
for t in opt_tags:
ax = axis_of.get(t)
if ax is None:
rule = row.get(t)
if isinstance(rule, dict):
r = _apply_rule(rule)
if isinstance(r, _ExcludeSentinel):
return EXCLUDE
factor *= r
else:
axis_members.setdefault(ax, []).append(t)
for members in axis_members.values():
best = 0.0
for t in members:
rule = row.get(t)
r = _apply_rule(rule) if isinstance(rule, dict) else unruled
best = max(best, 0.0 if isinstance(r, _ExcludeSentinel) else r)
# Every alternative this axis offered was excluded → the option drops.
if best <= 0.0:
return EXCLUDE
factor *= best
return factor
def combine_constraint_factor(
source_picks: list[dict[str, Any]] | None,
option: dict[str, Any],
matrix: dict[str, Any] | None,
exceptions: list[Any] | None,
axis_kinds: dict[str, str] | None = None,
target_axes: dict[str, list[str]] | None = None,
) -> _Factor:
matrix = matrix or {}
axis_kinds = axis_kinds or {}
target_axes = target_axes or {}
exc_by_pair: dict[tuple[str, str], dict] = {}
for e in (exceptions or []):
if not isinstance(e, dict):
@@ -35,6 +151,12 @@ def combine_constraint_factor(source_picks, option, matrix, exceptions):
t = e.get("target")
if isinstance(s, str) and isinstance(t, str):
exc_by_pair[(s, t)] = e
# Source values whose exceptions include an `only`: for those, a target
# value with no exception of its own is excluded. Read off the de-duplicated
# pair map so it agrees with the rule that actually applies to each pair.
only_sources = {
s for (s, _t), e in exc_by_pair.items() if e.get("mode") == "only"
}
opt_value = str(option.get("value", ""))
opt_tags = option.get("tags") or []
factor = 1.0
@@ -44,19 +166,47 @@ def combine_constraint_factor(source_picks, option, matrix, exceptions):
exc = exc_by_pair.get((p_value, opt_value))
if exc is not None:
r = _apply_rule(exc)
if r is EXCLUDE:
if isinstance(r, _ExcludeSentinel):
return EXCLUDE
factor *= r
continue
for s in p_tags:
row = matrix.get(s)
if not isinstance(row, dict):
if p_value in only_sources:
return EXCLUDE
# Partition this pick's tags: those inside an `accepts` axis fold with
# max, the rest keep multiplying. A pick only carries `axes` when the
# source wildcard declared the group `accepts`, and `axis_kinds` gates
# it a second time, so a legacy pick takes the flat path untouched.
pick_axes = pick.get("axes") or {}
accepts: dict[str, list[str]] = {}
claimed: set[str] = set()
for axis, members in pick_axes.items():
if axis_kinds.get(axis) != "accepts":
continue
for t in opt_tags:
rule = row.get(t)
if isinstance(rule, dict):
r = _apply_rule(rule)
if r is EXCLUDE:
return EXCLUDE
factor *= r
in_axis = [t for t in p_tags if t in (members or [])]
if in_axis:
accepts[axis] = in_axis
claimed.update(in_axis)
for s in p_tags:
if s in claimed:
continue
r = _cell_factor(matrix, s, opt_tags, target_axes)
if isinstance(r, _ExcludeSentinel):
return EXCLUDE
factor *= r
for members in accepts.values():
best = 0.0
for s in members:
r = _cell_factor(matrix, s, opt_tags, target_axes)
# One member's EXCLUDE is that member declining, not the axis
# declining — it contributes 0 to the max and the siblings
# still get their say.
best = max(best, 0.0 if isinstance(r, _ExcludeSentinel) else r)
# Every alternative the option offered was excluded, so the option
# really does drop out.
if best <= 0.0:
return EXCLUDE
factor *= best
return factor
+17 -6
View File
@@ -132,6 +132,8 @@ def apply_constraints_for_target(
hits=None,
firing_uid=None,
carrier_ctx=None,
applied_out=None,
target_axes=None,
) -> tuple[list[dict[str, Any]], bool]:
"""Apply EVERY constraint whose reach selector covers this firing
target instance, combined sequentially.
@@ -219,9 +221,18 @@ def apply_constraints_for_target(
continue
adjust_warnings: list[dict[str, Any]] = []
options = _apply_constraint_to_options(
options, c, src_pick, adjust_warnings,
options, c, src_pick, adjust_warnings, target_axes,
)
any_applied = True
# Record the (constraint, resolved source pick) pairs that actually
# covered THIS firing target instance. The caller (wildcard_handler)
# uses them to restrict the target's OWN `accepts`-axis roll to tags
# these same constraints don't exclude — so `$source.AXIS` and
# `$target.AXIS` agree under a diagonal. Only covered constraints land
# here, so a selector-limited constraint (`first`/`next`/`pick`) that
# skipped this instance also leaves the axis roll alone.
if applied_out is not None:
applied_out.append((c, src_pick))
for w in adjust_warnings:
_push_constraint_warning(
warnings,
@@ -242,10 +253,10 @@ def warn_excludes_all(
warnings: list[dict[str, Any]],
) -> None:
"""Emit the ``constraint_excludes_all_options`` warning when post-
application weights sum to zero. ``_pick_weighted`` falls back to
``options[0]`` silently — without this warning the user sees the
same option forever with no signal that constraints over-narrowed
the pool.
application weights sum to zero. ``_pick_weighted`` returns None for
such a pool and the wildcard binds an empty string, so without this
warning an over-narrowed pool is indistinguishable from a wildcard
that legitimately rolled its null option.
Call AFTER ``apply_constraints_for_target`` returned ``any_applied``.
"""
@@ -259,6 +270,6 @@ def warn_excludes_all(
target_uuid=target_uuid,
detail={
"target_wildcard_id": target_uuid,
"hint": "every option was excluded; falling back to options[0]",
"hint": "every option was excluded; the target emits nothing",
},
)
+24
View File
@@ -0,0 +1,24 @@
"""Per-module run detail for the WP Debug node.
A run opts in by setting ``ctx["__wp_explain__"] = True`` (the canvas Context
node does; the Test Runner's thousand-seed runs don't). The pipeline then gives
each module an empty ``ctx["__wp_module_detail__"]`` dict before it resolves and
files whatever the handler put there on that module's trace row as ``detail``.
Handlers call :func:`module_detail` and write only when it returns a dict, so
an un-opted run pays one dict lookup per module and nothing else.
"""
from __future__ import annotations
from typing import Any
EXPLAIN_KEY = "__wp_explain__"
DETAIL_KEY = "__wp_module_detail__"
def module_detail(ctx: Any) -> dict[str, Any] | None:
"""The active module's detail sink, or None when nobody asked for one."""
if not isinstance(ctx, dict):
return None
sink = ctx.get(DETAIL_KEY)
return sink if isinstance(sink, dict) else None
+68
View File
@@ -0,0 +1,68 @@
"""Wildcard fallback option (schema v9).
A wildcard may mark one non-null option ``fallback: true``. That option rolls
like any other (its weight, the node's filters + toggles and constraints all
apply), and is ALSO taken, without an rng draw, when nothing is left to pick:
every option, the fallback included, was excluded by a constraint (an Only
rule included) or weighted 0. Without a fallback such a wildcard binds an
empty string, which is what it did before v9 and still does. Flagging an
option never changes a normal draw. A fallback weighted 0 is only ever used
as the fallback.
Shared by the top-level wildcard handler and the nested ``@{uuid}`` resolver
so both surfaces agree. The node's filters + toggles decide whether the
fallback is available at all (``find_fallback`` runs after them).
"""
from __future__ import annotations
from typing import Any
def is_fallback(option: Any) -> bool:
"""True for an option flagged as the wildcard's fallback. The null option
can't be one (an empty fallback is what having none already means)."""
return (
isinstance(option, dict)
and option.get("fallback") is True
and not option.get("is_null")
)
def find_fallback(options: list[dict[str, Any]]) -> dict[str, Any] | None:
"""The first flagged option in ``options`` (validation allows one), as it
was before weights + constraints. It stays in the draw pool."""
return next((o for o in options if is_fallback(o)), None)
def pool_is_dead(options: list[dict[str, Any]]) -> bool:
"""True when a weighted draw over ``options`` has nothing to pick."""
total = 0.0
for o in options:
try:
total += max(0.0, float(o.get("weight", 1)))
except (TypeError, ValueError):
continue
return total <= 0
def fallback_warning(
target_uuid: str, fallback: dict[str, Any], *, constrained: bool,
) -> dict[str, Any]:
"""The info-level ``fallback_used`` note WP Debug and the Test Runner show
in place of ``constraint_excludes_all_options``."""
why = "constraints excluded every option" if constrained else "no option was left to pick"
return {
"type": "fallback_used",
"severity": "info",
"module_id": target_uuid,
"source_field": "",
"position": 0,
"token_index": None,
"detail": {
"target_wildcard_id": target_uuid,
"option_id": fallback.get("id"),
"value": fallback.get("value", ""),
"reason": "constraints" if constrained else "empty_pool",
},
"message": f"{why}; used the fallback {fallback.get('value', '')!r}",
}
+28 -2
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import re
from typing import Any
from engine import negatives
from engine.modules import build_resolve_ctx
from engine.modules._seed import derive_module_rng
from engine.modules.dispatcher import ModuleHandler
@@ -72,6 +73,9 @@ class CombineHandler(ModuleHandler):
for v in input_vars:
if not isinstance(v, str):
raise ValueError("combine payload.input_vars entries must be strings")
neg = payload.get("negative")
if neg is not None and not isinstance(neg, str):
raise ValueError("combine payload.negative must be a string")
@classmethod
def resolve(
@@ -101,7 +105,17 @@ 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":
# Held: reuse the frame-0 resolved value verbatim (from the
# pipeline's base pass) so a held combine's alternation stays
# frozen across the whole run, nested refs included.
_hb = ctx.get("__wp_hold_base_ctx__")
if isinstance(_hb, dict) and output_var in _hb:
negatives.copy_from(ctx, _hb, [output_var])
return {output_var: _hb[output_var]}
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)
@@ -113,5 +127,17 @@ class CombineHandler(ModuleHandler):
# picks up our derived RNG instead of the chain RNG.
ctx_local = {**ctx, "__wp_rng__": rng}
resolve_ctx = build_resolve_ctx(ctx_local, surface="combine")
result = resolve_text(template, resolve_ctx)
with negatives.collecting(resolve_ctx) as col:
result = resolve_text(template, resolve_ctx)
# Send-to-negative: the phrase carries the negatives of every variable
# its template rendered, plus its own (resolved quietly on the
# combine's negative stream so its {a|b} never shifts the value's).
own = negatives.clean_negative(payload.get("negative"))
entries = negatives.entries_for_reads(ctx, col.reads)
entries += negatives.own_entries(
[own] if own else [], resolve_ctx,
negatives.neg_rng(effective_seed, output_var),
pick=None, source=output_var,
)
negatives.set_entries(ctx, output_var, entries)
return {output_var: result}
+101 -3
View File
@@ -19,10 +19,13 @@ from __future__ import annotations
from typing import Any
from engine.modules._detail import module_detail
from engine.modules._keys import decode_key, encode_key
from engine.modules.dispatcher import ModuleHandler
_VALID_MODES = {"allow", "exclude", "boost", "reduce"}
# `only` (2026-09, linked picks) turns its row into an allow-list — see
# `_constraint_math`. Additive: a payload using it stamps schema_version 6.
_VALID_MODES = {"allow", "exclude", "boost", "reduce", "only"}
# SP3 reach selector — which downstream target instance(s) a constraint
# reaches. Shape only here; reach *behaviour* is applied by a later task.
@@ -142,6 +145,14 @@ def _ctx_set_constraint(ctx: Any, meta: dict[str, Any]) -> None:
})
def _override_mode(mode: Any) -> Any:
"""Canvas matrix builds before 2026-09 stored a neutral cell override as
``"neutral"`` (the editor's name) instead of ``"allow"`` (the engine's),
which failed the whole constraint at run time. Read it as ``allow`` so
workflows saved by those builds run."""
return "allow" if mode == "neutral" else mode
def _validate_cell(cell: Any, where: str) -> None:
if not isinstance(cell, dict):
raise ValueError(f"constraint {where} must be an object")
@@ -163,6 +174,55 @@ def _validate_cell(cell: Any, where: str) -> None:
raise ValueError(f"constraint {where}.factor must not be negative")
def exception_pair(exc: dict[str, Any]) -> tuple[str, str] | None:
"""The `(source, target)` key an exception is filed under, or None.
MUST match `_constraint_math.combine_constraint_factor`'s key resolution
exactly — tier-2 `source_value`/`target_value` first, legacy
`source`/`target` as fallback. If the two ever disagree, the duplicate
check below stops describing what the engine actually does.
"""
source = exc.get("source_value")
if source is None:
source = exc.get("source")
target = exc.get("target_value")
if target is None:
target = exc.get("target")
if isinstance(source, str) and isinstance(target, str):
return (source, target)
return None
def find_duplicate_exception_pairs(payload: dict[str, Any]) -> list[tuple[str, str]]:
"""`(source, target)` pairs appearing more than once, in first-seen order.
Two exceptions on the same pair cannot both apply. `combine_constraint_factor`
files them into a dict keyed by the pair, so the LAST one in the list wins
and every earlier one is discarded silently — and that includes discarding
an `exclude`, which the documented model says is absorbing:
exceptions [exclude, boost x3] -> factor 3.0 (exclusion defeated)
exceptions [boost x3, exclude] -> EXCLUDE
exceptions [boost x3, reduce x0.5] -> 0.5 (not the combined 1.5)
So the result depends on list order, which no surface shows and nothing
lets you edit. Reported by the maintainer 2026-08-06 after noticing the
editor would happily accept contradictory duplicates.
"""
seen: set[tuple[str, str]] = set()
duplicates: list[tuple[str, str]] = []
for exc in payload.get("exceptions") or []:
if not isinstance(exc, dict):
continue
pair = exception_pair(exc)
if pair is None:
continue
if pair in seen and pair not in duplicates:
duplicates.append(pair)
seen.add(pair)
return duplicates
class ConstraintHandler(ModuleHandler):
"""Records a constraint matrix into the context for downstream consumers."""
@@ -236,6 +296,31 @@ class ConstraintHandler(ModuleHandler):
)
_validate_target_select(payload.get("target_select"))
@classmethod
def validate_authoring(cls, payload: dict[str, Any]) -> None:
"""Extra rules applied when a payload is SAVED or IMPORTED, not when
it is executed.
Deliberately separate from `validate_payload`, which `resolve` calls on
every run. A rule enforced there would turn an existing bad payload
into a hard failure mid-graph — strictly worse than the silent
misbehaviour it replaces, and it would fire on data the user cannot
reach to fix without the graph running. Enforcing at the authoring
boundary stops new ones being created (from the editor, the API, or an
import) while leaving existing rows executable.
"""
duplicates = find_duplicate_exception_pairs(payload)
if duplicates:
listed = ", ".join(
f"({source!r} -> {target!r})" for source, target in duplicates
)
raise ValueError(
"constraint payload.exceptions contains more than one rule for "
f"the same source/target pair: {listed}. Only one can apply — "
"the engine keeps whichever comes last — so keep the rule you "
"want and delete the rest."
)
@classmethod
def resolve(
cls,
@@ -288,7 +373,7 @@ class ConstraintHandler(ModuleHandler):
# value across resolves.
merged = dict(cell)
if key in cell_mode_overrides:
mode = cell_mode_overrides[key]
mode = _override_mode(cell_mode_overrides[key])
if mode not in _VALID_MODES:
raise ValueError(
f"constraint cell_mode_overrides[{key!r}] must be one of "
@@ -328,7 +413,7 @@ class ConstraintHandler(ModuleHandler):
if len(parts) != 2:
continue
src, tgt = parts[0], parts[1]
mode = cell_mode_overrides.get(key, "allow")
mode = _override_mode(cell_mode_overrides.get(key, "allow"))
if mode not in _VALID_MODES:
raise ValueError(
f"constraint cell_mode_overrides[{key!r}] must be one of "
@@ -486,4 +571,17 @@ class ConstraintHandler(ModuleHandler):
"__constraint_bundle_origin__": bundle_origin,
}
_ctx_set_constraint(ctx, meta)
detail = module_detail(ctx)
if detail is not None:
cells = [c for row in matrix.values() for c in row.values()]
detail.update({
"uid": module_id or "",
"reach": ts,
"cells": len(cells),
"exceptions": len(exceptions),
"only": any(
isinstance(r, dict) and r.get("mode") == "only"
for r in (*cells, *exceptions)
),
})
return {}
+408 -55
View File
@@ -1,11 +1,16 @@
"""Derivation module resolver — IF/ELIF/ELSE rules over the runtime context.
Each rule has an ordered list of branches (index 0 == IF, the rest == ELIF)
and an optional ``else`` clause. Rules evaluate independently top-to-bottom:
and an optional ``else`` clause. A branch ``condition`` is either one test
(``{var, op, value}``) or a group (``{match: "all"|"any", conditions: [...]}``)
whose members are tests or further groups, so a branch can AND / OR several
tests together (schema v7). Rules evaluate independently top-to-bottom:
the first branch whose condition matches wins; if none match, the ``else``
action (if any) fires; otherwise the rule is a no-op.
Actions mutate the runtime context directly. Multiple rules can target the
A branch (and the ``else`` clause) runs its ``action`` and then, in order,
each entry of its optional ``extra_actions`` list (schema v8): "THEN ... AND
...". Actions mutate the runtime context directly. Multiple rules can target the
same variable; later rules see earlier mutations. Action ``value`` strings
are resolved through resolve_text with surface="derivation" so $var tokens
and {a|b|c} picks work inside derivation action values.
@@ -15,11 +20,18 @@ from __future__ import annotations
import re
from typing import Any
from engine import negatives
from engine.modules import build_resolve_ctx
from engine.modules._detail import module_detail
from engine.modules._seed import derive_module_rng
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
from engine.syntax.types import deref_var_value, split_var_accessor
from engine.syntax.resolve import NEG_ACCESSOR
from engine.syntax.types import (
deref_var_value,
parse_var_reference,
split_var_accessor,
)
_VALID_OPS = {
"equals", "not_equals", "contains", "matches",
@@ -30,8 +42,22 @@ _VALID_OPS = {
# semantics they need — wildcards can pick options with empty
# values, leaving keys present-but-empty.
"exists", "not_exists", "is_set", "is_unset",
# Emptiness refinements the editor's "exists" switch has always offered.
# `is_empty` is "present and empty" (a wildcard that rolled its null
# option); `is_not_empty` is "resolved to something non-empty".
"is_empty", "is_not_empty",
}
_VALID_MODES = {"replace", "append", "prepend"}
_VALID_MATCHES = {"all", "any"}
_MISSING = object()
# Groups nest; the editor stops at three levels, the cap here only guards a
# hand-built payload against runaway recursion.
_MAX_CONDITION_DEPTH = 8
# `negative` (schema v8, send-to-negative): "Add to negative" — the value is
# added to the target variable's negatives; the variable itself is untouched.
# `negative_replace` (also v8): "Replace negative" — the value becomes the
# variable's only negative, dropping what it carried.
NEGATIVE_MODES = {"negative", "negative_replace"}
_VALID_MODES = {"replace", "append", "prepend", *NEGATIVE_MODES}
def _ctx_get_raw(ctx: Any, name: str) -> Any:
@@ -56,14 +82,58 @@ def _ctx_get_raw(ctx: Any, name: str) -> Any:
return None
def _ctx_axis(ctx: Any, base: str, axis: str, index: int | None) -> str:
"""Read a rolled axis choice out of `ctx["__wp_axes__"]`.
Mirrors the resolver's `_resolve_axis` shaping so a condition and a
template agree on what `$outfit.SHOES` means. Missing yields "" rather than
raising: derivations are control flow, so an unanswerable condition is
simply false.
"""
if not isinstance(ctx, dict):
return ""
entry = (ctx.get("__wp_axes__") or {}).get(base)
if isinstance(entry, list):
# Positional, like the resolver: a pick with no tag on this axis keeps
# its slot so `.K` lines up with `$outfit.K`.
vals = [
(e.get(axis) or "") if isinstance(e, dict) else "" for e in entry
]
if index is None:
return ", ".join(v for v in vals if v)
return vals[index] if 0 <= index < len(vals) else ""
if isinstance(entry, dict):
got = entry.get(axis, "")
# A single pick behaves as a one-element list, as everywhere else.
if index is not None:
return got if index == 0 else ""
return got
return ""
def _ctx_get(ctx: Any, name: str) -> str:
"""Read `name` as a string, honoring a `.K` list accessor and folding a
ListVar to its joined value (SP2a): `$mood.0` splits to base `mood` +
index 0. The fold/accessor contract lives in deref_var_value."""
base, index = split_var_accessor(name)
"""Read `name` as a string, honoring a `.K` list accessor, a `.AXIS` read
(2026-08 tag axes), and folding a ListVar to its joined value (SP2a):
`$mood.0` splits to base `mood` + index 0. The fold/accessor contract lives
in deref_var_value; the axis contract in `_ctx_axis`."""
base, index, axis = parse_var_reference(name)
if axis == NEG_ACCESSOR:
return _ctx_negative(ctx, base, index)
if axis:
return _ctx_axis(ctx, base, axis, index)
return deref_var_value(_ctx_get_raw(ctx, base), index)
def _ctx_negative(ctx: Any, base: str, index: int | None) -> str:
"""`$name.neg` in a condition: the variable's negatives as one line,
read the same way the resolver renders them."""
texts = [
str(e.get("text", "")) for e in negatives.get_entries(ctx, base)
if index is None or e.get("pick") is None or e.get("pick") == index
]
return negatives.join_unique(texts)
def _ctx_has(ctx: Any, name: str) -> bool:
"""Whether `name` is a known key in `ctx`, regardless of value.
@@ -76,7 +146,23 @@ def _ctx_has(ctx: Any, name: str) -> bool:
"""
if ctx is None:
return False
name = split_var_accessor(name)[0] # SP2a: presence checks the base var
base, _index, axis = parse_var_reference(name)
if axis == NEG_ACCESSOR:
return bool(_ctx_negative(ctx, base, _index))
if axis:
# An axis is present when the binding actually rolled it. Checked
# against the axes bucket, not ctx: `$outfit.SHOES` can exist while
# `outfit` is unbound, and vice versa.
entry = (
(ctx.get("__wp_axes__") or {}).get(base)
if isinstance(ctx, dict) else None
)
if isinstance(entry, dict):
return axis in entry
if isinstance(entry, list):
return any(isinstance(e, dict) and axis in e for e in entry)
return False
name = base # SP2a: presence checks the base var
# Engine ctx is a dict in tests + at runtime; check `__contains__`
# before falling back to a getter probe so we get the cheap path.
try:
@@ -129,6 +215,10 @@ def _match_condition(condition: dict[str, Any], ctx: Any) -> bool:
return _ctx_has(ctx, var) and _ctx_get(ctx, var) != ""
if op == "is_unset":
return not _ctx_has(ctx, var) or _ctx_get(ctx, var) == ""
if op == "is_empty":
return _ctx_has(ctx, var) and _ctx_get(ctx, var) == ""
if op == "is_not_empty":
return _ctx_get(ctx, var) != ""
actual = _ctx_get(ctx, var)
if op == "equals":
return actual == value
@@ -144,6 +234,91 @@ def _match_condition(condition: dict[str, Any], ctx: Any) -> bool:
return False
def is_condition_group(condition: Any) -> bool:
"""A group carries ``conditions``; a single test carries ``var``."""
return isinstance(condition, dict) and "conditions" in condition
def condition_leaves(condition: Any) -> list[dict[str, Any]]:
"""Every single test in ``condition``, depth-first. The position in this
list is the test's override index (see ``condition_override_key``)."""
if is_condition_group(condition):
out: list[dict[str, Any]] = []
for child in condition.get("conditions") or []:
out.extend(condition_leaves(child))
return out
return [condition] if isinstance(condition, dict) else []
def condition_override_key(branch_index: int, leaf_index: int) -> str:
"""Key into ``condition_value_overrides[rule_id]`` for one test of a
branch. The first test keeps the bare branch index it always had, so a
single-test branch and its saved overrides are unchanged; later tests of a
grouped branch append ``.K`` (their depth-first position).
MUST stay byte-identical to the TS twin ``conditionOverrideKey`` in
``src/extension/derivation-conditions.ts``.
"""
return str(branch_index) if leaf_index == 0 else f"{branch_index}.{leaf_index}"
def _with_value_overrides(
condition: Any, overrides: dict[str, Any], branch_index: int,
) -> Any:
"""Copy of ``condition`` with each test's ``value`` swapped for its
instance override, when one is set."""
counter = [0]
def walk(node: Any) -> Any:
if is_condition_group(node):
return {**node, "conditions": [walk(c) for c in node.get("conditions") or []]}
if not isinstance(node, dict):
return node
key = condition_override_key(branch_index, counter[0])
counter[0] += 1
override = overrides.get(key)
return {**node, "value": override} if isinstance(override, str) else node
return walk(condition)
def _eval_condition(condition: Any, ctx: Any) -> bool:
if is_condition_group(condition):
children = condition.get("conditions") or []
if condition.get("match") == "any":
return any(_eval_condition(c, ctx) for c in children)
return all(_eval_condition(c, ctx) for c in children)
if not isinstance(condition, dict):
return False
return _match_condition(condition, ctx)
def _explain_condition(condition: Any, ctx: Any) -> dict[str, Any]:
"""The condition tree with each test's outcome and the value it read, for
the WP Debug node. Evaluates every test, unlike ``_eval_condition``, so a
group that short-circuited still shows why each member did or didn't
match. Reads only; nothing here writes to ``ctx``."""
if is_condition_group(condition):
children = [_explain_condition(c, ctx) for c in condition.get("conditions") or []]
match = "any" if condition.get("match") == "any" else "all"
results = [c["result"] for c in children]
return {
"match": match,
"result": any(results) if match == "any" else all(results),
"conditions": children,
}
if not isinstance(condition, dict):
return {"var": "", "op": "", "value": "", "actual": None, "result": False}
var = str(condition.get("var", ""))
return {
"var": var,
"op": str(condition.get("op", "")),
"value": condition.get("value", ""),
"actual": _ctx_get(ctx, var) if _ctx_has(ctx, var) else None,
"result": _match_condition(condition, ctx),
}
def branch_carrier_key(rule_id: str, branch: int | str) -> str:
"""Carrier key for a derivation branch occurrence -- the `option_id` the
SP3 nested-occurrence model matches a constraint `pick` against. `branch`
@@ -161,12 +336,30 @@ def _apply_action(
ctx: Any,
resolve_ctx: Any,
carrier_key: str | None = None,
) -> tuple[str, str] | None:
neg_seed: int | None = None,
neg_key: str | None = None,
) -> tuple[str, str | None] | None:
"""Run one action. Returns (target, new value), with value None for an
"Add to negative" / "Replace negative" action (it writes no value), or None when it did
nothing. `neg_seed` + `neg_key` key the action's negative stream
(`neg_key` defaults to the carrier key)."""
target = action.get("target_var", "")
if not target:
return None
mode = action.get("mode", "replace")
raw_value = str(action.get("value", ""))
n_rng = negatives.neg_rng(
neg_seed or 0, f"derivation::{neg_key or carrier_key or target}",
)
if mode in NEGATIVE_MODES:
text = negatives.quiet_resolve(raw_value, resolve_ctx, n_rng)
negatives.set_entries(
ctx, target,
[{"text": text, "pick": None, "source": carrier_key or "derivation"}],
add=mode == "negative",
)
return target, None
col = negatives.Collector()
# Stamp this derivation instance as the CARRIER of any `@{}` in the action
# value, keyed by the branch key (`${rule_id}:${bi}` / `${rule_id}:else`) —
# the `option_id` the SP3 nested-occurrence model matches a `pick` against.
@@ -181,26 +374,91 @@ def _apply_action(
prev = get_carrier()
set_carrier(module_uid, carrier_key)
try:
new_value = resolve_text(raw_value, resolve_ctx)
with negatives.collecting(resolve_ctx) as col:
new_value = resolve_text(raw_value, resolve_ctx)
finally:
set_carrier(*prev)
else:
new_value = resolve_text(raw_value, resolve_ctx)
with negatives.collecting(resolve_ctx) as col:
new_value = resolve_text(raw_value, resolve_ctx)
if mode not in ("replace", "append", "prepend"):
return None
# The written text carries the negatives of the variables it read and of
# the options its refs picked. Replace swaps the target's negatives for
# those; append / prepend keep the old ones and add.
entries = negatives.entries_for_reads(ctx, col.reads)
entries += negatives.own_entries(
col.ref_negatives, resolve_ctx, n_rng,
pick=None, source=carrier_key or "derivation",
)
negatives.set_entries(ctx, target, entries, add=mode != "replace")
if mode == "replace":
result = new_value
elif mode == "append":
result = _ctx_get(ctx, target) + new_value
elif mode == "prepend":
result = new_value + _ctx_get(ctx, target)
else:
return None
result = new_value + _ctx_get(ctx, target)
_ctx_set(ctx, target, result)
return target, result
def _validate_condition(condition: Any, where: str) -> None:
def clause_actions(clause: Any) -> list[dict[str, Any]]:
"""Every action a branch or ``else`` clause runs, in order: its ``action``
then its ``extra_actions``. Non-dict entries are skipped."""
if not isinstance(clause, dict):
return []
out = [clause.get("action")]
extra = clause.get("extra_actions")
if isinstance(extra, list):
out.extend(extra)
return [a for a in out if isinstance(a, dict)]
def action_override_key(branch: int | str, action_index: int) -> str:
"""Key into ``action_value_overrides[rule_id]`` for one action of a branch
(``branch`` is the index or ``"else"``). The first action keeps the bare
key it always had; later ones append ``.K``.
MUST stay byte-identical to the TS twin ``actionOverrideKey`` in
``src/extension/derivation-conditions.ts``.
"""
return str(branch) if action_index == 0 else f"{branch}.{action_index}"
def _explain_action(
action: dict[str, Any], pair: tuple[str, str | None] | None,
) -> dict[str, Any]:
"""What a fired branch did: the variable, how it wrote, and the result."""
return {
"target": str(action.get("target_var", "")),
"mode": str(action.get("mode", "replace")),
"value": str(action.get("value", "")),
"result": pair[1] if pair is not None else None,
}
def _validate_condition(condition: Any, where: str, depth: int = 0) -> None:
if not isinstance(condition, dict):
raise ValueError(f"derivation {where}.condition must be an object")
if is_condition_group(condition):
if depth >= _MAX_CONDITION_DEPTH:
raise ValueError(
f"derivation {where}.condition nests deeper than "
f"{_MAX_CONDITION_DEPTH} groups"
)
if condition.get("match") not in _VALID_MATCHES:
raise ValueError(
f"derivation {where}.condition.match must be one of "
f"{sorted(_VALID_MATCHES)}"
)
children = condition.get("conditions")
if not isinstance(children, list) or not children:
raise ValueError(
f"derivation {where}.condition.conditions must be a non-empty list"
)
for ci, child in enumerate(children):
_validate_condition(child, f"{where}.condition.conditions[{ci}]", depth + 1)
return
var = condition.get("var")
if not isinstance(var, str) or not var:
raise ValueError(f"derivation {where}.condition.var must be a non-empty string")
@@ -230,6 +488,16 @@ def _validate_action(action: Any, where: str) -> None:
raise ValueError(f"derivation {where}.action.value must be a string")
def _validate_extra_actions(clause: dict[str, Any], where: str) -> None:
extra = clause.get("extra_actions")
if extra is None:
return
if not isinstance(extra, list):
raise ValueError(f"derivation {where}.extra_actions must be a list")
for ai, action in enumerate(extra):
_validate_action(action, f"{where}.extra_actions[{ai}]")
class DerivationHandler(ModuleHandler):
"""IF / ELIF / ELSE rule evaluator that mutates ctx."""
@@ -273,6 +541,7 @@ class DerivationHandler(ModuleHandler):
branch.get("condition"), f"rules[{ri}].branches[{bi}]"
)
_validate_action(branch.get("action"), f"rules[{ri}].branches[{bi}]")
_validate_extra_actions(branch, f"rules[{ri}].branches[{bi}]")
else_clause = rule.get("else")
if else_clause is not None:
if not isinstance(else_clause, dict):
@@ -280,6 +549,7 @@ class DerivationHandler(ModuleHandler):
f"derivation payload.rules[{ri}].else must be an object"
)
_validate_action(else_clause.get("action"), f"rules[{ri}].else")
_validate_extra_actions(else_clause, f"rules[{ri}].else")
@classmethod
def resolve(
@@ -295,8 +565,30 @@ class DerivationHandler(ModuleHandler):
# action.value across runs; without lock the chain seed
# propagates per-queue. Falls back to the host ctx's RNG when
# the ctx wasn't built with `__wp_rng__` (legacy callers).
# seed_scope == "hold" freezes the DICE per branch, NOT which branch
# fires. The conditions still pick a branch live each iteration, so a
# varying input can switch IF→ELIF and we use THAT branch — but every
# branch's inline {a|b} / @{} roll is frozen to its frame-0 value via
# the constant hold seed, keyed PER BRANCH (see _branch_resolve_ctx).
# So each branch's value is identical every iteration it fires and
# equals the value it WOULD have had at frame 0, independent of which
# other branches fired. (Contrast wildcard/combine/fixed_values hold,
# which freeze the whole resolved VALUE because a constraint can
# reshape their pool; a derivation branch's pool is fixed, so a frozen
# seed alone is enough — and freezing the branch SELECTION would be
# wrong: the user wants the chosen branch to follow the inputs.)
_hold = False
effective_seed = 0
module_id = "derivation"
ctx_local: dict[str, Any] = {}
if isinstance(ctx, dict) and "__wp_rng__" in ctx:
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
_hold = instance.get("seed_scope") == "hold"
if _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")
effective_seed = (
int(locked_seed) if isinstance(locked_seed, (int, float))
@@ -309,6 +601,19 @@ class DerivationHandler(ModuleHandler):
else:
resolve_ctx = build_resolve_ctx(ctx, surface="derivation")
def _branch_resolve_ctx(branch_key: str) -> Any:
# Non-hold (or no rng): the shared rng stream — existing
# behaviour, branch values draw in fire order. Hold: an
# INDEPENDENT rng per branch (keyed by rule:branch) so its value
# is frozen to its frame-0 roll regardless of which other
# branches drew before it in the shared stream.
if not _hold:
return resolve_ctx
rng_b = derive_module_rng(effective_seed, f"{module_id}::{branch_key}")
return build_resolve_ctx(
{**ctx_local, "__wp_rng__": rng_b}, surface="derivation"
)
# Tier-D instance overrides (2026-05-10 cycle).
disabled_rule_ids = set(instance.get("disabled_rule_ids") or [])
disabled_branch_keys = set(instance.get("disabled_branch_keys") or [])
@@ -328,10 +633,76 @@ class DerivationHandler(ModuleHandler):
)
out: dict[str, str] = {}
# WP Debug: which branch of each rule fired and why (explain runs only).
detail = module_detail(ctx)
explained: list[dict[str, Any]] | None = [] if detail is not None else None
def run_clause(
rule_id: str, key: int | str, clause: dict[str, Any],
rd: dict[str, Any] | None,
) -> None:
"""Run a fired branch's (or the else clause's) actions in order.
Each action sees the writes of the ones before it. Action-value
overrides are keyed per action (``action_override_key``)."""
carrier = branch_carrier_key(rule_id, key)
rule_overrides = action_overrides.get(rule_id)
b_ctx = _branch_resolve_ctx(carrier)
shown: list[dict[str, Any]] = []
saved: dict[str, Any] = {}
for ai, action in enumerate(clause_actions(clause)):
override = (
rule_overrides.get(action_override_key(key, ai))
if isinstance(rule_overrides, dict) else None
)
if isinstance(override, str):
action = {**action, "value": override}
# Later actions key their negative stream apart so two
# "Add to negative" actions on one branch never share rolls.
pair = _apply_action(
action, ctx, b_ctx,
carrier_key=carrier,
neg_seed=effective_seed,
neg_key=carrier if ai == 0 else f"{carrier}.{ai}",
)
if pair is not None and pair[1] is not None:
out[pair[0]] = pair[1]
# The next action of this branch reads what this one
# wrote. Scoped to the branch (restored below) so rules
# keep resolving their values exactly as before.
vars_view = getattr(b_ctx, "_vars", None)
if isinstance(vars_view, dict):
base = split_var_accessor(pair[0])[0]
if base not in saved:
saved[base] = vars_view.get(base, _MISSING)
vars_view[base] = pair[1]
shown.append(_explain_action(action, pair))
vars_view = getattr(b_ctx, "_vars", None)
if isinstance(vars_view, dict):
for base, prev in saved.items():
if prev is _MISSING:
vars_view.pop(base, None)
else:
vars_view[base] = prev
if rd is not None:
rd["fired"] = key
if shown:
rd["action"] = shown[0]
rd["actions"] = shown
for rule in rules:
rule_id = rule.get("id", "")
rd: dict[str, Any] | None = None
if explained is not None:
rd = {
"id": rule_id,
"fired": None,
"branches": [],
"has_else": isinstance(rule.get("else"), dict),
}
explained.append(rd)
if rule_id in disabled_rule_ids:
if rd is not None:
rd["disabled"] = True
continue
applied = False
@@ -340,55 +711,37 @@ class DerivationHandler(ModuleHandler):
# even when listed because disabling IF == disabling rule
# (the per-rule toggle handles that case cleanly).
if bi != 0 and branch_carrier_key(rule_id, bi) in disabled_branch_keys:
if rd is not None:
rd["branches"].append({"index": bi, "disabled": True})
continue
# Condition-value override per branch index.
# Condition-value overrides, one per test of the branch.
cond = branch.get("condition", {})
cond_override = (
cond_overrides.get(rule_id, {}).get(str(bi))
if isinstance(cond_overrides.get(rule_id), dict)
else None
)
if isinstance(cond_override, str):
cond = {**cond, "value": cond_override}
rule_cond_overrides = cond_overrides.get(rule_id)
if isinstance(rule_cond_overrides, dict) and rule_cond_overrides:
cond = _with_value_overrides(cond, rule_cond_overrides, bi)
if _match_condition(cond, ctx):
# Action-value override per branch index.
action = branch.get("action", {})
action_override = (
action_overrides.get(rule_id, {}).get(str(bi))
if isinstance(action_overrides.get(rule_id), dict)
else None
)
if isinstance(action_override, str):
action = {**action, "value": action_override}
pair = _apply_action(
action, ctx, resolve_ctx, carrier_key=branch_carrier_key(rule_id, bi)
)
if pair is not None:
out[pair[0]] = pair[1]
matched = _eval_condition(cond, ctx)
if rd is not None:
rd["branches"].append({
"index": bi,
"matched": matched,
"condition": _explain_condition(cond, ctx),
})
if matched:
run_clause(rule_id, bi, branch, rd)
applied = True
break
if not applied:
# ELSE skip when listed in disabled_branch_keys.
if branch_carrier_key(rule_id, "else") in disabled_branch_keys:
if rd is not None:
rd["else_disabled"] = True
continue
else_clause = rule.get("else")
if isinstance(else_clause, dict):
action = else_clause.get("action", {})
# ELSE action-value override under `else` key.
action_override = (
action_overrides.get(rule_id, {}).get("else")
if isinstance(action_overrides.get(rule_id), dict)
else None
)
if isinstance(action_override, str):
action = {**action, "value": action_override}
pair = _apply_action(
action, ctx, resolve_ctx, carrier_key=branch_carrier_key(rule_id, "else")
)
if pair is not None:
out[pair[0]] = pair[1]
run_clause(rule_id, "else", else_clause, rd)
if detail is not None:
detail["rules"] = explained
return out
+51 -2
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import re
from typing import Any
from engine import negatives
from engine.modules import build_resolve_ctx
from engine.modules._seed import derive_module_rng
from engine.modules.dispatcher import ModuleHandler
@@ -84,6 +85,11 @@ class FixedValuesHandler(ModuleHandler):
raise ValueError(
f"fixed_values payload.values[{i}].value must be a string"
)
neg = v.get("negative")
if neg is not None and not isinstance(neg, str):
raise ValueError(
f"fixed_values payload.values[{i}].negative must be a string"
)
@classmethod
def resolve(
@@ -134,7 +140,8 @@ class FixedValuesHandler(ModuleHandler):
enabled = instance.get("enabled_options")
if enabled is not None:
allowed = set(enabled)
values = [v for v in values if v.get("id") in allowed]
keys = _row_keys(values)
values = [v for v, k in zip(values, keys, strict=True) if k in allowed]
# Syntax-aware emission path. When ctx is missing required keys
# for build_resolve_ctx (legacy callers passing None / minimal
@@ -150,7 +157,21 @@ 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":
# Held: reuse each name's frame-0 resolved value verbatim (from the
# pipeline's base pass) so a held fixed-values alternation stays
# frozen across the whole run.
_hb = ctx.get("__wp_hold_base_ctx__")
if isinstance(_hb, dict):
_names = [n for n in ((v.get("name") or "").strip() for v in values) if n]
if _names and all(n in _hb for n in _names):
negatives.copy_from(ctx, _hb, _names)
return {n: _hb[n] for n in _names}
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)
@@ -166,6 +187,14 @@ class FixedValuesHandler(ModuleHandler):
ctx_local = {**ctx, "__wp_rng__": rng}
resolve_ctx = build_resolve_ctx(ctx_local, surface="fixed_values")
# Negatives are library content, like the value itself: an instance
# override list can re-word values but never re-words a negative, so
# look each one up on the library row (by id, then by name).
lib_rows = [v for v in payload.get("values", []) if isinstance(v, dict)]
lib_by_id = {v.get("id"): v for v in lib_rows if v.get("id")}
lib_by_name = {(v.get("name") or "").strip(): v for v in lib_rows}
n_rng = negatives.neg_rng(effective_seed, module_key)
out_resolved: dict[str, str] = {}
for v in values:
name = (v.get("name") or "").strip()
@@ -173,9 +202,29 @@ class FixedValuesHandler(ModuleHandler):
continue
raw = str(v.get("value", ""))
out_resolved[name] = resolve_text(raw, resolve_ctx)
lib = lib_by_id.get(v.get("id")) or lib_by_name.get(name) or {}
own = negatives.clean_negative(lib.get("negative"))
negatives.set_entries(ctx, name, negatives.own_entries(
[own] if own else [], resolve_ctx, n_rng, pick=None, source=name,
))
return out_resolved
def _row_keys(values: list[Any]) -> list[str]:
"""The key each row is toggled by: its ``id``, or ``#<index>`` when the id
is missing, blank or repeats an earlier row's. Imported packs can carry
rows without ids. Mirrors ``withRowKeys`` in
src/components/context/editors/fixed-values/defaults.ts."""
seen: set[str] = set()
keys: list[str] = []
for i, v in enumerate(values):
rid = v.get("id") if isinstance(v, dict) else None
key = rid if isinstance(rid, str) and rid.strip() and rid not in seen else f"#{i}"
seen.add(key)
keys.append(key)
return keys
def _ctx_supports_resolve(ctx: Any) -> bool:
"""build_resolve_ctx requires __wp_rng__ + __wp_warnings__ in ctx.
Legacy test/usage passes None or minimal dicts — detect those and
+37 -2
View File
@@ -211,7 +211,7 @@ class WalkResult:
walk_overflow: list[WalkOverflow] = field(default_factory=list)
def _extract_at_uuid_refs(payload: dict[str, Any]) -> set[str]:
def extract_at_uuid_refs(payload: dict[str, Any]) -> set[str]:
"""Collect all ``@{8hex}`` refs referenced inside a payload's
string-typed leaves. Uses ``tokenize_text`` exclusively — only
canonical 8-hex-char refs (spec §2.4) are followed.
@@ -260,6 +260,41 @@ def _row_to_snapshot_entry(
}
def ref_seed_uuids(modules: Iterable[Any] | None) -> set[str]:
"""Every ``@{uuid}`` target referenced by ANY module in a node's list.
A catalog is synthesised from wildcard modules only, and
``walk_transitive_refs`` follows refs found in wildcard payloads only. That
covers a ref authored inside a wildcard option, but ``@{uuid}`` is legal on
other surfaces too — most notably a derivation rule's action value, and the
per-instance ``action_value_overrides`` / ``condition_value_overrides`` the
edit modal writes when a user retargets a branch on the canvas.
Refs on those surfaces were never discovered, so their target reached the
catalog only by coincidence — when the user happened to ALSO have that
wildcard picked in the same node. Removing the wildcard from the node (a
reasonable thing to do once a derivation references it by uuid) dropped it
with nothing to put it back, and every such ref resolved to ``unknown_ref``
→ empty string no matter what the live library held.
Seeds therefore come from each module's ``payload`` AND its ``instance``:
the instance is where a canvas-side retarget lives, and it is not part of
the library payload the walker would otherwise see.
Tolerant of malformed rows — a corrupt widget value must never break a
graph run.
"""
seeds: set[str] = set()
for module in modules or []:
if not isinstance(module, dict):
continue
for key in ("payload", "instance"):
value = module.get(key)
if isinstance(value, dict):
seeds |= extract_at_uuid_refs(value)
return seeds
def walk_transitive_refs(
root_uuids: Iterable[str],
*,
@@ -347,7 +382,7 @@ def walk_transitive_refs(
continue
new_ancestors = ancestors | {uuid}
for child_uuid in _extract_at_uuid_refs(row["payload"]):
for child_uuid in extract_at_uuid_refs(row["payload"]):
queue.append((child_uuid, depth + 1, uuid, new_ancestors))
# After walk: re-stamp every dep's parent list (BFS may have accumulated
+550 -37
View File
@@ -20,7 +20,9 @@ from __future__ import annotations
import re
from typing import Any
from engine import negatives
from engine.modules import build_resolve_ctx
from engine.modules._detail import module_detail
from engine.modules._seed import derive_module_rng as _derive_module_rng
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
@@ -58,13 +60,51 @@ def _coerce_pick_int(value: Any, default: int) -> int:
return default
def _weight(o: dict[str, Any]) -> float:
try:
return max(0.0, float(o.get("weight", 1)))
except (TypeError, ValueError):
return 0.0
def _explain_pool(detail: dict[str, Any], options: list[dict[str, Any]]) -> None:
"""Pool size and how many options can still be drawn, for WP Debug."""
detail["pool"] = len(options)
detail["live"] = sum(1 for o in options if _weight(o) > 0)
def _explain_constraints(
detail: dict[str, Any], applied: list[tuple[dict[str, Any], dict[str, Any]]],
) -> None:
"""The constraints that re-weighted this pick and the source value each
one keyed on."""
if not applied:
return
detail["constraints"] = [
{
"id": c.get("__constraint_library_id__") or "",
"uid": c.get("__constraint_module_id__") or "",
"name": c.get("__constraint_library_name__") or "",
"source": c.get("source_wildcard_id") or "",
"source_value": src.get("value") if isinstance(src, dict) else None,
}
for c, src in applied
]
def _pick_weighted(options: list[dict[str, Any]], rng) -> dict[str, Any] | None:
"""Pick one option weighted by `weight`. Uses rng (no module-global random)."""
if not options:
return None
total = sum(max(0.0, float(o.get("weight", 1))) for o in options)
if total <= 0:
return options[0]
# Nothing is selectable — every option is either weight 0 (documented
# as "disable without deleting") or was excluded by a constraint.
# Returning options[0] here looked like a working pick: a real option,
# right shape, right place, and wrong. It stayed wrong silently until
# the list was reordered. Callers turn None into an empty binding,
# which is what the null option already means.
return None
r = rng.random() * total
acc = 0.0
for opt in options:
@@ -79,6 +119,7 @@ def _apply_constraint_to_options(
constraint: dict[str, Any],
source_pick: dict[str, Any],
adjustment_warnings: list[dict[str, Any]] | None = None,
target_axes: dict[str, list[str]] | None = None,
) -> list[dict[str, Any]]:
"""Adjust option weights according to a single constraint, given the
already-picked source option(s).
@@ -107,7 +148,7 @@ def _apply_constraint_to_options(
combine fn treats as a no-op.
These are diagnostic only; they never change the computed weight.
"""
from engine.modules._constraint_math import EXCLUDE, combine_constraint_factor
from engine.modules._constraint_math import combine_constraint_factor
matrix = constraint.get("matrix") or {}
exceptions = constraint.get("exceptions") or []
@@ -131,17 +172,33 @@ def _apply_constraint_to_options(
picks, options, matrix, exceptions, adjustment_warnings,
)
# A pick only carries an `axes` entry when the source declared that group
# `accepts` (see `_axis_menus`), so the pick record IS the kind map. This
# keeps `combine_constraint_factor` pure — it never looks up a payload —
# and a legacy pick with no `axes` yields an empty map, which takes the
# unchanged flat-product path.
axis_kinds = {
axis: "accepts"
for p in picks
for axis in (p.get("axes") or {})
}
adjusted: list[dict[str, Any]] = []
for opt in options:
option = {"value": opt.get("value", ""), "tags": opt.get("sub_categories") or []}
f = combine_constraint_factor(picks, option, matrix, exceptions)
f = combine_constraint_factor(
picks, option, matrix, exceptions, axis_kinds, target_axes,
)
# `isinstance(f, float)` rather than `f is not EXCLUDE`: identity
# against a sentinel does not narrow the union, so the multiply below
# was unprovable. Same test, and the type checker can follow it.
weight = float(opt.get("weight", 1))
weight = 0.0 if f is EXCLUDE else max(0.0, weight * float(f))
weight = max(0.0, weight * f) if isinstance(f, float) else 0.0
adjusted.append({**opt, "weight": weight})
return adjusted
_KNOWN_CONSTRAINT_MODES = frozenset({"allow", "exclude", "boost", "reduce"})
_KNOWN_CONSTRAINT_MODES = frozenset({"allow", "exclude", "boost", "reduce", "only"})
def _exc_pair_index(exceptions: list[Any]) -> dict[tuple[str, str], dict[str, Any]]:
@@ -205,8 +262,8 @@ def _warn_for_rule(
adjustment_warnings: list[dict[str, Any]],
) -> None:
mode = rule.get("mode")
if mode == "allow":
# `allow` is a weight no-op; a non-1 factor is almost certainly a
if mode in ("allow", "only"):
# `allow` / `only` weigh 1.0; a non-1 factor is almost certainly a
# user expecting weight×factor and silently getting full weight.
try:
factor = float(rule.get("factor", 1.0))
@@ -257,7 +314,213 @@ def _file_pick_by_origin(
entry["by_origin"] = by_origin
def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
def _axis_menus(
payload: dict[str, Any], tags: list[str] | None
) -> dict[str, list[str]]:
"""The option's tags, grouped by `accepts` axis, in registry order.
Only `accepts` groups appear — a `classify` group's tags stay in the flat
bag where the constraint fold multiplies them, which is today's behaviour.
An axis the option says nothing about is omitted rather than mapped to an
empty list, so callers can treat presence as meaning.
"""
kinds = payload.get("tag_group_kinds") or {}
groups = payload.get("tag_groups") or {}
have = set(tags or [])
out: dict[str, list[str]] = {}
for axis, members in groups.items():
if kinds.get(axis) != "accepts":
continue
in_axis = [m for m in (members or []) if m in have]
if in_axis:
out[axis] = in_axis
return out
def _accepts_axes(payload: dict[str, Any]) -> dict[str, list[str]]:
"""Every ``accepts`` axis on this payload as group → full member list
(independent of any option). Fed to the constraint fold as the TARGET side:
a target option's tags inside one of these groups are ALTERNATIVES, so the
fold keeps the option viable when the source allows any of them (symmetric
to the source's rolled-winner collapse), and the target's own roll is later
restricted to the allowed members. Empty ⇒ the fold takes the flat product
path, i.e. today's behaviour.
"""
kinds = payload.get("tag_group_kinds") or {}
groups = payload.get("tag_groups") or {}
return {
g: list(members or [])
for g, members in groups.items()
if kinds.get(g) == "accepts"
}
def _roll_axes(menus: dict[str, list[str]], rng) -> dict[str, str]:
"""One winner per axis, uniform over the option's own menu.
Uniform rather than weighted: option `weight` describes how often the
OPTION is chosen, and nothing in the payload expresses a preference between
an option's accepted tags. Inventing one would be a guess.
Axis order is the payload's group order, so the draw sequence is stable
across runs for a given seed.
"""
return {
axis: members[int(rng.random() * len(members))]
for axis, members in menus.items()
if members
}
def _pick_record_for_constraint(
chosen: dict[str, Any], payload: dict[str, Any], rolled: dict[str, str] | None
) -> dict[str, Any]:
"""The per-pick record a constraint reads. For an ``accepts`` axis the
option offers a menu but rolls ONE winner; the constraint keys on that
single winner (the value ``$var.AXIS`` exposes), NOT the whole menu —
otherwise a diagonal narrows the target to the entire accepted set instead
of the rolled tag, and ``$source.AXIS`` disagrees with the picked target
(the reported "picks are not good"). So each accepts axis collapses to its
winner: the axis members leave the flat ``tags`` bag and the winner is added
back, and ``axes`` carries only the winner. Classify tags are untouched
(they still multiply). An option with no accepts axis produces the same
record as before the feature.
"""
sub = list(chosen.get("sub_categories") or [])
menus = _axis_menus(payload, sub)
rolled = rolled or {}
axis_members = {m for members in menus.values() for m in members}
winners = [rolled[a] for a in menus if a in rolled]
tags = [t for t in sub if t not in axis_members] + winners
axes = {a: [rolled[a]] for a in menus if a in rolled}
return {"value": chosen.get("value", ""), "tags": tags, "axes": axes}
def _restrict_menus_by_constraints(
menus: dict[str, list[str]],
applied_constraints: list[tuple[dict[str, Any], dict[str, Any]]] | None,
) -> dict[str, list[str]]:
"""Drop from each accepts-axis menu the tags a FIRED constraint excludes, so
the target's own roll can only land on a constraint-allowed tag.
Under a diagonal from a source that rolled ``sandals``, the target's
``{sandals, heels}`` option can no longer roll ``heels`` — so
``$source.SHOES`` and ``$target.SHOES`` agree. A tag survives iff EVERY
fired constraint gives a single-tag option ``{tags: [t]}`` a positive
factor. Never empties an axis: if every member is excluded the menu is left
intact (the option itself survived the pool narrowing, so its axis must
still roll something) — reachable only with a pathological matrix.
"""
if not applied_constraints or not menus:
return menus
from engine.modules._constraint_math import combine_constraint_factor
out: dict[str, list[str]] = {}
for axis, members in menus.items():
allowed: list[str] = []
for t in members:
ok = True
for c, src_pick in applied_constraints:
s_picks = src_pick.get("picks")
if not isinstance(s_picks, list):
s_picks = [{
"value": src_pick.get("value", ""),
"tags": list(src_pick.get("sub_categories") or []),
}]
axis_kinds = {
ax: "accepts"
for p in s_picks for ax in (p.get("axes") or {})
}
# The probe is a bare tag with no real value, so a
# value-level `only` would shut it out and leave the menu
# unrestricted. The option itself already passed that gate
# in the pool, so only the tag rules matter here.
probe_excs = [
e for e in (c.get("exceptions") or [])
if not (isinstance(e, dict) and e.get("mode") == "only")
]
f = combine_constraint_factor(
s_picks, {"value": "", "tags": [t]},
c.get("matrix") or {}, probe_excs, axis_kinds,
)
if not (isinstance(f, float) and f > 0.0):
ok = False
break
if ok:
allowed.append(t)
out[axis] = allowed if allowed else members
return out
def _record_axes(
ctx: Any, binding: str, rolled: Any, payload: dict[str, Any]
) -> None:
"""Publish the rolled axis choices under the VARIABLE binding.
Deliberately not on the pick record: `$outfit.SHOES` addresses by binding,
while `__wp_picks__` is keyed by module uuid. Keying each consumer the way
it addresses things means neither has to translate, and the axis read never
inherits the uuid-bucket collision (task_5200c1fc) that only reaches
consumers binding by uuid.
"""
if not isinstance(ctx, dict) or not binding:
return
bucket = ctx.setdefault("__wp_axes__", {})
if isinstance(bucket, dict):
bucket[binding] = rolled
# Which accepts axes this binding's wildcard DECLARES, whatever the pick
# rolled. `__wp_axes__` alone cannot tell "the picked option has no SHOES
# tag" from "there is no SHOES axis", and the resolver's warning needs to
# say which one happened. Always written (even empty) so a later wildcard
# rebinding the same name replaces the earlier declaration.
decl = ctx.setdefault("__wp_axis_decl__", {})
if isinstance(decl, dict):
decl[binding] = list(_accepts_axes(payload))
def _match_variable_option(
ctx: Any, options: list[dict[str, Any]], var_name: str,
) -> tuple[dict[str, Any] | None, str]:
"""Option for a ``match_variable`` instance, and how it was chosen
(``"match_variable"`` or ``"match_fallback"``). ``(None, "")`` means roll
normally; a ``match_variable_no_match`` warning says why."""
from engine.modules._fallback import find_fallback
name = var_name[1:] if var_name.startswith("$") else var_name
raw = ctx.get(name) if ctx is not None else None
wanted = "" if raw is None else str(raw).strip().lower()
if wanted:
for o in options:
if o.get("value") is None:
continue
if str(o.get("value", "")).strip().lower() == wanted:
return o, "match_variable"
fallback = find_fallback(options)
if fallback is not None:
return fallback, "match_fallback"
if ctx is not None and isinstance(ctx.get("__wp_warnings__"), list):
module_id = ctx.get("__wp_current_module_id__") or ""
ctx["__wp_warnings__"].append({
"type": "match_variable_no_match",
"severity": "warn",
"module_id": module_id,
"source_field": "",
"position": 0,
"token_index": None,
"detail": {"variable": name, "value": "" if raw is None else str(raw)},
"message": (
f"no option equals ${name} ({'' if raw is None else str(raw)!r}) "
"and none is marked fallback; rolled normally"
),
})
return None, ""
def _record_pick(
ctx: Any,
chosen: dict[str, Any],
payload: dict[str, Any],
rolled: dict[str, str] | None = None,
) -> None:
"""Stash the picked option dict in `ctx["__wp_picks__"][module_id]` so a
downstream constraint-aware wildcard can look up its source's value +
sub_categories. Keyed by the active module id (set by pipeline.py).
@@ -282,10 +545,10 @@ def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
# SP3: per-pick list the combine fn reads. A single pick is a
# one-element list so the multi-tag/multi-pick applier has a
# uniform shape regardless of single- vs multi-select source.
"picks": [{
"value": chosen.get("value", ""),
"tags": list(chosen.get("sub_categories") or []),
}],
# An `accepts` axis is collapsed to its rolled winner here (see
# `_pick_record_for_constraint`) so the constraint keys on the one
# tag `$var.AXIS` exposes, not the whole menu.
"picks": [_pick_record_for_constraint(chosen, payload, rolled)],
}
# Additive per-instance view (task_5200c1fc). When this wildcard
# carries a `bundle_origin`, ALSO file the pick under
@@ -298,7 +561,13 @@ def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
bucket[module_id] = entry
def _record_pick_multi(ctx: Any, chosen_list: list[dict[str, Any]], sep: str) -> None:
def _record_pick_multi(
ctx: Any,
chosen_list: list[dict[str, Any]],
sep: str,
payload: dict[str, Any],
rolled_list: list[dict[str, str]] | None = None,
) -> None:
"""Multi-select counterpart to `_record_pick` (SP2a). Stash the joined
value + the individual picked values + the union of their sub-categories,
so the debug Picks view can render the list. Single-pick records (via
@@ -318,10 +587,15 @@ def _record_pick_multi(ctx: Any, chosen_list: list[dict[str, Any]], sep: str) ->
union.append(t)
# SP3: per-pick list (value + that pick's own tags, NOT the union) so the
# combine fn can apply the matrix per pick. The union above stays for the
# debug Picks view; `picks` is what constraint application reads.
# debug Picks view; `picks` is what constraint application reads. Each
# pick's `accepts` axis is collapsed to its own rolled winner (parallel to
# the single-pick path) so a multi-select source contributes one tag per
# pick, not each pick's whole menu.
per_pick = [
{"value": str(c.get("value", "")), "tags": list(c.get("sub_categories") or [])}
for c in chosen_list
_pick_record_for_constraint(
c, payload, (rolled_list[i] if rolled_list and i < len(rolled_list) else None)
)
for i, c in enumerate(chosen_list)
]
entry = {
"value": sep.join(values),
@@ -335,6 +609,32 @@ def _record_pick_multi(ctx: Any, chosen_list: list[dict[str, Any]], sep: str) ->
bucket[module_id] = entry
def _file_negatives(
ctx: Any,
binding: str,
resolve_ctx: Any,
seed: int,
picks: list[tuple[dict[str, Any], list[str], int | None]],
) -> None:
"""File the negatives of this roll under `binding`.
Each pick contributes its option's own `negative` plus the negatives of
the options its nested `@{ref}`s picked, resolved quietly on the
binding's negative stream so the positive picks never shift. `slot` is
the multi-pick index (None for a single pick)."""
if not isinstance(ctx, dict):
return
rng = negatives.neg_rng(seed, binding)
entries: list[dict[str, Any]] = []
for opt, ref_negs, slot in picks:
own = negatives.clean_negative(opt.get("negative"))
texts = ([own] if own else []) + list(ref_negs)
entries += negatives.own_entries(
texts, resolve_ctx, rng, pick=slot, source=binding,
)
negatives.set_entries(ctx, binding, entries)
class WildcardHandler(ModuleHandler):
type_id = "wildcard"
@@ -421,8 +721,41 @@ class WildcardHandler(ModuleHandler):
)
seen_grouped.add(mem)
# Group KIND (2026-08 tag axes). Additive: absence means every group
# is `classify`, which is exactly today's behaviour, so no stored
# payload changes meaning. `accepts` marks the group's tags as
# alternatives the option offers — an OR-set the constraint fold reads
# with max, and which `$var.AXIS` rolls one winner from.
kinds = payload.get("tag_group_kinds")
if kinds is not None:
if not isinstance(kinds, dict):
raise ValueError(
"wildcard payload.tag_group_kinds must be an object"
)
declared = tag_groups if isinstance(tag_groups, dict) else {}
for gname, kind in kinds.items():
if gname not in declared:
raise ValueError(
f"wildcard payload.tag_group_kinds[{gname!r}]: "
f"not a declared tag group"
)
if kind not in ("classify", "accepts"):
raise ValueError(
f"wildcard payload.tag_group_kinds[{gname!r}] must be "
f"'classify' or 'accepts' (got {kind!r})"
)
# Only an `accepts` axis is addressable as `$var.AXIS`, so only
# it has to survive the accessor grammar. A `classify` group
# keeps whatever name it has today.
if kind == "accepts" and not _IDENT_RE.match(gname):
raise ValueError(
f"wildcard payload.tag_group_kinds[{gname!r}]: an "
f"'accepts' group name must be a valid identifier"
)
seen_ids: set[str] = set()
null_count = 0
fallback_count = 0
for i, opt in enumerate(options):
if not isinstance(opt, dict):
raise ValueError(f"wildcard payload.options[{i}] must be an object")
@@ -466,6 +799,27 @@ class WildcardHandler(ModuleHandler):
f"wildcard payload.options[{i}].value must be a "
f"non-empty string (use is_null=True for the null option)"
)
# Send-to-negative (schema v8): optional words this option puts
# in the negative prompt of any Assembler that renders it.
neg = opt.get("negative")
if neg is not None and not isinstance(neg, str):
raise ValueError(
f"wildcard payload.options[{i}].negative must be a string"
)
# Fallback (schema v9): the one option used when nothing else is
# left to pick. Stored only as `true`; absent means not.
fb = opt.get("fallback")
if fb is not None and not isinstance(fb, bool):
raise ValueError(
f"wildcard payload.options[{i}].fallback must be a boolean"
)
if fb:
if is_null:
raise ValueError(
f"wildcard payload.options[{i}] null option "
f"{opt_id!r} can't be the fallback"
)
fallback_count += 1
weight = opt.get("weight", 1)
if not isinstance(weight, (int, float)) or isinstance(weight, bool):
raise ValueError(
@@ -496,6 +850,11 @@ class WildcardHandler(ModuleHandler):
f"wildcard payload may have at most one null option "
f"(found {null_count})"
)
if fallback_count > 1:
raise ValueError(
f"wildcard payload may have at most one fallback option "
f"(found {fallback_count})"
)
binding = payload.get("var_binding")
if binding is not None:
if not isinstance(binding, str):
@@ -542,20 +901,52 @@ class WildcardHandler(ModuleHandler):
# has since removed. Honors enabled_options + weights normally
# in every other mode.
mode = instance.get("mode")
pinned: dict[str, Any] | None = None
pin_mode = "pinned"
if mode == "pinned":
pinned_id = instance.get("pinned_option_id")
pinned = next((o for o in options if o.get("id") == pinned_id), None)
if pinned is not None:
# Track the pinned pick the same way as a random pick —
# downstream constraint-aware wildcards need source
# info regardless of how the source resolved its option.
_record_pick(ctx, pinned)
value = str(pinned.get("value", ""))
if not value:
return {binding: ""}
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
return {binding: resolve_text(value, resolve_ctx)}
# else: pinned target is missing — fall through to random.
# `match_variable`: take the option whose text equals another
# variable's value (case-insensitive), e.g. `$model_variant` from WP
# Model Info, so this wildcard's pick can drive constraints. No match →
# the fallback option if one is flagged, else a normal roll + warning.
match_var = instance.get("match_variable")
if pinned is None and isinstance(match_var, str) and match_var.strip():
pinned, pin_mode = _match_variable_option(ctx, options, match_var.strip())
if pinned is not None:
# A pinned option still rolls its axes: pinning fixes WHICH
# option fires, not which of the shoes it accepts. Roll BEFORE
# recording so the pick carries the single rolled winner (the
# constraint source-view a downstream target reads), same as the
# random path. Pinned bypasses this wildcard's own constraint
# application, so there's nothing to restrict the roll against.
pinned_rolled = _roll_axes(
_axis_menus(payload, pinned.get("sub_categories")),
_derive_module_rng(
int(ctx.get("__wp_node_seed__", 0) or 0), f"{binding}::axes",
),
)
# Track the pinned pick the same way as a random pick —
# downstream constraint-aware wildcards need source
# info regardless of how the source resolved its option.
_record_pick(ctx, pinned, payload, pinned_rolled)
_record_axes(ctx, binding, pinned_rolled, payload)
detail = module_detail(ctx)
if detail is not None:
detail.update({"mode": pin_mode, "option_id": pinned.get("id")})
if pin_mode != "pinned":
detail["match_variable"] = match_var.strip()
value = str(pinned.get("value", ""))
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
with negatives.collecting(resolve_ctx) as col:
out_value = resolve_text(value, resolve_ctx) if value else ""
_file_negatives(
ctx, binding, resolve_ctx,
int(ctx.get("__wp_node_seed__", 0) or 0),
[(pinned, col.ref_negatives, None)],
)
return {binding: out_value}
# else: nothing pinned or matched — fall through to random.
# `category_filter` narrows the option pool to entries whose tag
# set (`sub_categories`) satisfies a boolean expression
@@ -580,6 +971,25 @@ class WildcardHandler(ModuleHandler):
allowed = set(enabled)
options = [o for o in options if o.get("id") in allowed]
# Schema v9 fallback: the flagged option stays in the draw pool and
# is also taken when weights + constraints leave nothing to pick. Found
# after the node's filters + toggles, so a toggled-off one is gone.
from engine.modules._fallback import (
fallback_warning,
find_fallback,
pool_is_dead,
)
fallback = find_fallback(options)
detail = module_detail(ctx)
if detail is not None:
if isinstance(category_filter, str) and category_filter.strip():
detail["filter"] = category_filter.strip()
if exclude_null:
detail["exclude_null"] = True
if not options:
detail.update({"pool": 0, "live": 0})
if not options:
return {binding: ""}
@@ -614,6 +1024,11 @@ class WildcardHandler(ModuleHandler):
)
my_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
any_constraint_applied = False
# (constraint, source_pick) pairs that actually re-weighted this target.
# Used below to restrict this wildcard's OWN accepts-axis roll to tags
# those constraints allow, so `$source.AXIS` == `$target.AXIS` under a
# diagonal (A). Empty when nothing constrains this instance.
applied_constraints: list[tuple[dict[str, Any], dict[str, Any]]] = []
if my_id:
constraints = ctx.get("__wp_constraints__") if ctx is not None else None
picks = ctx.get("__wp_picks__") if ctx is not None else None
@@ -633,9 +1048,27 @@ class WildcardHandler(ModuleHandler):
options, any_constraint_applied = apply_constraints_for_target(
options, my_id, constraints, picks, ctx["__wp_warnings__"],
hits=hits, firing_uid=firing_uid,
applied_out=applied_constraints,
# TARGET-side accepts axes: a target option's tags in one of
# these groups are alternatives the option offers, so the fold
# keeps it viable when the source allows any of them (mirror of
# the source rolled-winner collapse). The roll is then pinned to
# the allowed member by `_restrict_menus_by_constraints`.
target_axes=_accepts_axes(payload),
)
if any_constraint_applied:
if any_constraint_applied and fallback is None:
warn_excludes_all(options, my_id or "", ctx["__wp_warnings__"])
if detail is not None:
_explain_pool(detail, options)
_explain_constraints(detail, applied_constraints)
use_fallback = fallback is not None and pool_is_dead(options)
if use_fallback:
assert fallback is not None
ctx["__wp_warnings__"].append(fallback_warning(
my_id or "", fallback, constrained=any_constraint_applied,
))
if detail is not None:
detail["fallback"] = True
# Effective seed selection:
# - locked_seed when present → reproducible per-instance
@@ -645,7 +1078,20 @@ 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":
# Held: reuse the frame-0 fully-resolved value verbatim (from the
# pipeline's base pass). Skips the pick AND nested-ref resolution,
# so a constrained or @{}-bearing wildcard stays frozen across the
# whole run — "green jeans" on frame 1 stays "green jeans".
_hb = ctx.get("__wp_hold_base_ctx__")
if isinstance(_hb, dict) and binding in _hb:
if detail is not None:
detail["held"] = True
negatives.copy_from(ctx, _hb, [binding])
return {binding: _hb[binding]}
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:
@@ -671,7 +1117,11 @@ class WildcardHandler(ModuleHandler):
weights = [max(0.0, float(o.get("weight", 1))) for o in pool]
pool_n = len(pool)
independent = bool(instance.get("pick_independent", False))
if independent:
if use_fallback:
# One fallback, never repeated: it stands in for the whole
# pick, so a `2-3` range doesn't render "hair, hair".
picks = [fallback] if hi > 0 else []
elif independent:
# SP2c: independent multi-pick draws WITH replacement (repeats
# allowed), mirroring the inline `~` flag — so the count is the
# requested range and is NOT clamped to the pool size.
@@ -688,30 +1138,88 @@ class WildcardHandler(ModuleHandler):
if not isinstance(sep, str):
sep = ", "
items: list[str] = []
neg_picks: list[tuple[dict[str, Any], list[str], int | None]] = []
saved_rng_multi = ctx.get("__wp_rng__")
ctx["__wp_rng__"] = rng
try:
multi_ctx = build_resolve_ctx(ctx, surface="wildcard")
for opt in picks:
items.append(resolve_text(str(opt.get("value", "")), multi_ctx))
for slot, opt in enumerate(picks):
with negatives.collecting(multi_ctx) as col:
items.append(resolve_text(str(opt.get("value", "")), multi_ctx))
neg_picks.append((opt, col.ref_negatives, slot))
finally:
ctx["__wp_rng__"] = saved_rng_multi
_record_pick_multi(ctx, picks, sep)
_file_negatives(ctx, binding, multi_ctx, effective_seed, neg_picks)
# One roll per pick, in pick order, so `$outfit.SHOES` mirrors the
# shape of `$outfit` and `$outfit.1.SHOES` lines up with `$outfit.1`.
# Each pick's accepts menu is first restricted to the tags the fired
# constraints allow (A), then rolled; the winners feed BOTH the pick
# record (source view) and the accessor. Rolled before the record —
# the record draws no rng and the restriction consumes none + keeps
# one draw per axis, so downstream reproducibility is unchanged.
rolled_list = [
_roll_axes(
_restrict_menus_by_constraints(
_axis_menus(payload, o.get("sub_categories")),
applied_constraints,
),
rng,
)
for o in picks
]
_record_pick_multi(ctx, picks, sep, payload, rolled_list)
if detail is not None:
detail["option_ids"] = [o.get("id") for o in picks]
detail["range"] = [lo, hi]
if independent:
detail["independent"] = True
_record_axes(ctx, binding, rolled_list, payload)
return {binding: ListVar(items, sep)}
chosen = _pick_weighted(options, rng)
# The fallback is taken without a draw, so no rng is consumed where
# the pre-v9 path returned empty.
chosen = fallback if use_fallback else _pick_weighted(options, rng)
if chosen is None:
return {binding: ""}
if detail is not None:
total = sum(_weight(o) for o in options)
detail["option_id"] = chosen.get("id")
# No draw happened for a fallback, so there is no chance to show
# (Debug reads `fallback` instead).
detail["chance"] = (
None if use_fallback
else _weight(chosen) / total if total > 0 else None
)
# Roll the accepts axes AFTER the option draw (same rng, so a locked
# seed still reproduces the option) but BEFORE recording the pick, so
# the pick a downstream target reads carries the single rolled winner
# per axis (source fix). This wildcard's OWN menu is first restricted to
# tags the constraints that fired on it allow (A) — so a constrained
# `$target.AXIS` agrees with the source. The restriction consumes no rng
# and keeps one draw per axis, so downstream reproducibility holds.
rolled = _roll_axes(
_restrict_menus_by_constraints(
_axis_menus(payload, chosen.get("sub_categories")),
applied_constraints,
),
rng,
)
# Record this wildcard's pick so a downstream constraint-aware
# wildcard can read it. Done BEFORE resolve_text so even an
# empty-string-value pick is registered (matters if a
# constraint exception keys on the literal empty pick value).
_record_pick(ctx, chosen)
_record_pick(ctx, chosen, payload, rolled)
_record_axes(ctx, binding, rolled, payload)
value = str(chosen.get("value", ""))
if not value:
# Empty / null pick — no ref to resolve.
# Empty / null pick — no ref to resolve. Its own negative (a
# "none" option that rules something out) still files.
_file_negatives(
ctx, binding, build_resolve_ctx(ctx, surface="wildcard"),
effective_seed, [(chosen, [], None)],
)
return {binding: ""}
# Swap `ctx['__wp_rng__']` to the per-module rng for the
@@ -744,8 +1252,13 @@ class WildcardHandler(ModuleHandler):
set_carrier(
ctx.get("__wp_current_module_uid__"), chosen.get("id"),
)
resolved = resolve_text(value, resolve_ctx)
with negatives.collecting(resolve_ctx) as col:
resolved = resolve_text(value, resolve_ctx)
finally:
ctx["__wp_rng__"] = saved_rng
_file_negatives(
ctx, binding, resolve_ctx, effective_seed,
[(chosen, col.ref_negatives, None)],
)
return {binding: resolved}
+298
View File
@@ -0,0 +1,298 @@
"""Per-variable negative prompts (schema v8, send-to-negative).
A wildcard option, a fixed value, a combine and a derivation "Add to
negative" action can carry negative text. The engine files it under the
variable it belongs to, in ``ctx["__wp_negatives__"]``:
{binding: [{"text": str, "pick": int | None, "source": str}, ...]}
``pick`` is the multi-pick slot the entry belongs to (``None`` = the whole
value), so ``$props.1`` can carry only pick 1's negative while ``$props`` and
``$props.AXIS`` carry every pick's. Text is stored already resolved.
Negatives follow USAGE: an Assembler only emits the negatives of variables
its positive template actually rendered. A combine (and a derivation value)
inherits the negatives of the variables its text read.
Seeds never shift: negative text resolves on its own stream,
``derive_module_rng(seed, key + "::neg")``, and in QUIET mode (see
:func:`quiet_resolve`) so an ``@{ref}`` inside a negative binds nothing,
counts no constraint hits and writes nothing to the Test Runner ref log.
Pure engine module: no ComfyUI imports.
"""
from __future__ import annotations
import dataclasses
import random
import re
from collections.abc import Callable, Iterable, Iterator
from contextlib import contextmanager
from typing import Any
NEG_KEY = "__wp_negatives__"
#: Bindings whose negatives a handler set during the current module. The
#: pipeline clears the negatives of every OTHER binding the module wrote
#: (a write replaces the value, so it replaces what the value carried).
TOUCHED_KEY = "__wp_neg_touched__"
Entry = dict[str, Any]
def neg_rng(seed: int, key: str) -> random.Random:
"""The negative stream for a module keyed ``key`` under ``seed``."""
from engine.modules._seed import derive_module_rng # noqa: PLC0415 - cycle
return derive_module_rng(int(seed), f"{key}::neg")
def clean_negative(raw: Any) -> str:
"""A payload ``negative`` field as a stripped string ('' when absent)."""
return raw.strip() if isinstance(raw, str) else ""
def table(ctx: dict[str, Any]) -> dict[str, list[Entry]]:
got = ctx.get(NEG_KEY)
if not isinstance(got, dict):
got = {}
ctx[NEG_KEY] = got
return got
def get_entries(ctx: Any, binding: str) -> list[Entry]:
if not isinstance(ctx, dict):
return []
got = ctx.get(NEG_KEY)
if not isinstance(got, dict):
return []
rows = got.get(binding)
return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
def set_entries(
ctx: Any, binding: str, entries: Iterable[Entry], *, add: bool = False,
) -> None:
"""Replace (default) or extend ``binding``'s negatives and mark it touched
so the pipeline's write step keeps them."""
if not isinstance(ctx, dict) or not binding:
return
rows = [e for e in entries if isinstance(e, dict) and str(e.get("text", "")).strip()]
tbl = table(ctx)
if add:
rows = get_entries(ctx, binding) + rows
if rows:
tbl[binding] = rows
else:
tbl.pop(binding, None)
touched = ctx.get(TOUCHED_KEY)
if not isinstance(touched, list):
touched = []
ctx[TOUCHED_KEY] = touched
if binding not in touched:
touched.append(binding)
def settle_writes(ctx: dict[str, Any], written: Iterable[str]) -> None:
"""After a module's bindings land: a written binding the module did not
give negatives to loses its old ones (the value was replaced)."""
touched = ctx.pop(TOUCHED_KEY, None)
touched = set(touched) if isinstance(touched, list) else set()
tbl = ctx.get(NEG_KEY)
if not isinstance(tbl, dict):
return
for key in written:
if key not in touched:
tbl.pop(key, None)
def copy_from(ctx: Any, source: Any, bindings: Iterable[str]) -> None:
"""Held modules reuse their frame-0 value verbatim; reuse its negatives."""
for b in bindings:
set_entries(ctx, b, [dict(e) for e in get_entries(source, b)])
def entries_for_reads(ctx: Any, reads: Iterable[tuple[str, Any]]) -> list[Entry]:
"""The negatives a text inherits from the ``$var`` reads it made.
``(name, None)`` takes every entry; ``(name, K)`` takes the entries of
pick K plus the whole-value ones. Order follows the reads; an entry is
taken once even when read twice. Inherited entries lose their pick slot:
they now belong to the reader as a whole.
"""
out: list[Entry] = []
seen: set[tuple[str, int]] = set()
for name, index in reads:
for i, e in enumerate(get_entries(ctx, name)):
pick = e.get("pick")
if index is not None and pick is not None and pick != index:
continue
if (name, i) in seen:
continue
seen.add((name, i))
out.append({**e, "pick": None})
return out
class Collector:
"""What one resolve touched: ``$var`` reads and nested-ref option picks."""
def __init__(self) -> None:
self.reads: list[tuple[str, Any]] = []
self.ref_negatives: list[str] = []
@contextmanager
def collecting(resolve_ctx: Any) -> Iterator[Collector]:
"""Point a resolve context's read/ref hooks at a fresh collector for the
duration of the block. Works on any ResolveContext; one without the hook
fields simply collects nothing."""
col = Collector()
has = hasattr(resolve_ctx, "_reads") and hasattr(resolve_ctx, "_ref_negs")
if not has:
yield col
return
saved = (resolve_ctx._reads, resolve_ctx._ref_negs)
resolve_ctx._reads = col.reads
resolve_ctx._ref_negs = col.ref_negatives
try:
yield col
finally:
resolve_ctx._reads, resolve_ctx._ref_negs = saved
def quiet_resolve(text: str, resolve_ctx: Any, rng: random.Random) -> str:
"""Resolve negative text without side effects on the positive run.
Draws from ``rng`` (the negative stream), applies no constraints and
counts no hits, writes no ref log, collects no reads, and ignores the
negatives of options its refs pick (no chaining). Warnings still land.
"""
if not text:
return ""
from engine.syntax import resolve_text # noqa: PLC0415 - import cycle
if dataclasses.is_dataclass(resolve_ctx) and not isinstance(resolve_ctx, type):
quiet = dataclasses.replace(
resolve_ctx,
rng=rng,
_constraints=[],
_hits={},
_ref_log=None,
_reads=None,
_ref_negs=None,
)
else:
quiet = resolve_ctx
return resolve_text(text, quiet).strip()
def own_entries(
texts: Iterable[str], resolve_ctx: Any, rng: random.Random,
*, pick: int | None, source: str,
) -> list[Entry]:
"""Quietly resolve each raw negative text into an entry."""
out: list[Entry] = []
for raw in texts:
text = quiet_resolve(raw, resolve_ctx, rng) if raw else ""
if text:
out.append({"text": text, "pick": pick, "source": source})
return out
# ---------------------------------------------------------------------------
# Tag handling (Assembler join + Cleaner dedupe)
# ---------------------------------------------------------------------------
_WEIGHT_WRAP = re.compile(r"^\(+\s*(.*?)\s*(?::\s*[0-9.]+)?\s*\)+$")
def split_tags(text: str) -> list[str]:
"""Split on commas that are not inside (), [] or {}."""
out: list[str] = []
depth = 0
cur: list[str] = []
for ch in text:
if ch in "([{":
depth += 1
elif ch in ")]}" and depth > 0:
depth -= 1
if ch == "," and depth == 0:
out.append("".join(cur).strip())
cur = []
else:
cur.append(ch)
out.append("".join(cur).strip())
return [t for t in out if t]
def tag_key(tag: str) -> str:
"""Comparison key: case-folded, whitespace-collapsed, and a single
``(tag:1.2)`` / ``((tag))`` weight wrap removed so emphasis variants of
one tag compare equal."""
t = " ".join(tag.split()).casefold()
m = _WEIGHT_WRAP.match(t)
if m and "(" not in m.group(1) and ")" not in m.group(1):
t = m.group(1).strip()
return t
def join_unique(texts: Iterable[str], skip: Iterable[str] = ()) -> str:
"""Join negative texts tag by tag, dropping repeats (first one wins) and
any tag whose key is in ``skip``."""
seen = {tag_key(s) for s in skip}
out: list[str] = []
for text in texts:
for tag in split_tags(text):
k = tag_key(tag)
if k in seen:
continue
seen.add(k)
out.append(tag)
return ", ".join(out)
NEGATIVES_SLOT = "$negatives"
_SLOT_RE = re.compile(r"\$negatives(?![A-Za-z0-9_])")
def has_slot(template: str) -> bool:
return bool(_SLOT_RE.search(template or ""))
def render_negative(
template: str, collected: str, resolve_raw: Callable[[str], str],
) -> str:
"""Fill the Assembler's negative template.
``$negatives`` marks where the collected words go. An empty template is
just the collected words; a template without the slot gets them appended.
``resolve_raw`` renders the template's other text (its ``$vars``) without
tidying. Tags the template already contains are dropped from the
collected words so nothing is said twice.
"""
tpl = template or ""
if not tpl.strip():
return collected
pieces = _SLOT_RE.split(tpl)
rendered = [resolve_raw(p) if p else "" for p in pieces]
fixed_tags: list[str] = []
for r in rendered:
fixed_tags.extend(split_tags(r))
words = join_unique([collected], skip=fixed_tags)
if len(pieces) == 1:
base = tidy(rendered[0])
if not words:
return base
return f"{base}, {words}" if base else words
return tidy(words.join(rendered))
_COMMA_RUN = re.compile(r"\s*,(?:\s*,)*\s*")
_WS_RUN = re.compile(r"[ \t]{2,}")
def tidy(text: str) -> str:
"""Collapse whitespace and empty comma slots; trim edge commas."""
out = _WS_RUN.sub(" ", text)
out = _COMMA_RUN.sub(", ", out)
return out.strip().strip(",").strip()
+349 -27
View File
@@ -13,10 +13,13 @@ import random
import re
from typing import Any
from engine import negatives, prefs
from engine.context import Context
from engine.modules import Module
from engine.modules._detail import DETAIL_KEY, EXPLAIN_KEY
from engine.modules.dispatcher import UnknownModuleType, resolve_module
from engine.modules.snapshot import coerce_legacy_module
from engine.sweep import apply_pin_override
logger = logging.getLogger(__name__)
@@ -194,30 +197,31 @@ def _extract_static_meta(
if b:
meta["binding"] = b
elif m_type == "derivation":
# Derivation can write to MULTIPLE target_vars (one per branch
# `action.target_var`, plus the `else.action.target_var`).
# Collect every declared target so the disabled-derivation row
# shows all of them in the debug viewer instead of a uuid.
# Derivation can write to MULTIPLE target_vars (every action of
# every branch, plus the else clause's; a clause runs `action` then
# its `extra_actions`). Collect every declared target so the
# disabled-derivation row shows all of them in the debug viewer
# instead of a uuid.
seen_targets: list[str] = []
rules = payload.get("rules")
if isinstance(rules, list):
for rule in rules:
if not isinstance(rule, dict):
continue
for branch in rule.get("branches", []) or []:
if not isinstance(branch, dict):
continue
action = branch.get("action")
if isinstance(action, dict):
target = action.get("target_var")
if isinstance(target, str):
target = target.lstrip("$").strip()
if target and target not in seen_targets:
seen_targets.append(target)
else_block = rule.get("else")
if isinstance(else_block, dict):
action = else_block.get("action")
if isinstance(action, dict):
clauses = [
b for b in rule.get("branches", []) or []
if isinstance(b, dict)
]
if isinstance(rule.get("else"), dict):
clauses.append(rule["else"])
for clause in clauses:
extra = clause.get("extra_actions")
actions = [clause.get("action")] + (
extra if isinstance(extra, list) else []
)
for action in actions:
if not isinstance(action, dict):
continue
target = action.get("target_var")
if isinstance(target, str):
target = target.lstrip("$").strip()
@@ -236,6 +240,84 @@ def _extract_static_meta(
return meta
def _module_seed_scope(module: object) -> str | None:
"""Read a module's base ``instance.seed_scope`` ("hold"/"vary"/None).
Works for both dict and object module shapes. seed_scope is a base-only
setting (disabled in per-frame edit mode), so the base instance is
authoritative."""
inst = module.get("instance") if isinstance(module, dict) else getattr(module, "instance", None)
if isinstance(inst, dict):
s = inst.get("seed_scope")
return s if isinstance(s, str) else None
return None
def _module_binding(module: object) -> str | None:
"""Read the variable name a wildcard binds to, instance override first.
Mirrors the precedence in `WildcardHandler.resolve`. Needed because rolled
axes are keyed by BINDING — the namespace `$outfit.SHOES` addresses — while
picks are keyed by module id, so the two carry-forwards cannot share a key.
"""
inst = (
module.get("instance") if isinstance(module, dict)
else getattr(module, "instance", None)
)
if isinstance(inst, dict):
b = inst.get("variable_binding")
if isinstance(b, str) and b:
return b
payload = (
module.get("payload") if isinstance(module, dict)
else getattr(module, "payload", None)
)
if isinstance(payload, dict):
b = payload.get("var_binding")
if isinstance(b, str) and b:
return b
return None
def _display_name(module: object, fallback: str) -> str:
"""The name a user knows a module by: the stack row's `name`, else its
library `meta.name`."""
meta = module.get("meta") if isinstance(module, dict) else getattr(module, "meta", None)
if isinstance(meta, dict):
name = meta.get("name")
if isinstance(name, str) and name:
return fallback or name
return fallback
def _finish_module(
ctx: dict[str, Any],
warn_mark: int,
module_id: str,
module_uid: str,
name: str,
) -> dict[str, Any]:
"""Explain-mode extras for one module's trace row.
Stamps every warning the module raised with its owner (resolver warnings
such as `unknown_ref` carry no module id of their own) and returns the
fields the trace row gains: the display name and whatever the handler put
in its detail sink. Only called when the run asked to explain itself.
"""
warnings = ctx.get("__wp_warnings__")
if isinstance(warnings, list):
for w in warnings[warn_mark:]:
if isinstance(w, dict) and not w.get("owner_uid"):
w["owner_id"] = module_id
w["owner_uid"] = module_uid or module_id
extras: dict[str, Any] = {}
if name:
extras["name"] = name
detail = ctx.pop(DETAIL_KEY, None)
if isinstance(detail, dict) and detail:
extras["detail"] = detail
return extras
class PipelineEngine:
"""Runs an ordered list of modules against a context dict."""
@@ -244,20 +326,118 @@ 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__", [])
ctx.setdefault("__wp_internal_flags__", {})
# Send-to-negative: per-variable negatives (engine/negatives.py).
ctx.setdefault(negatives.NEG_KEY, {})
# `@{uuid}` nesting limit from Settings (engine/prefs.py). setdefault
# so a caller (or a test) that pinned a limit keeps it; set here
# rather than per surface so canvas runs, preview and the Test Runner
# all honor the same value. `__`-prefixed, so it never crosses a
# socket — each node re-reads it at its own run.
ctx.setdefault("__wp_max_ref_depth__", prefs.max_ref_depth())
# SP3 reach selector: per-constraint firing count keyed by
# `__constraint_module_id__`. Drives first/next/all/pick
# coverage in apply_constraints_for_target + the never_applied /
# partial_reach finalisation below. Replaces the pre-SP3
# one-shot consumed-set.
ctx.setdefault("__wp_constraint_hits__", {})
# Canvas runs ask for per-module detail (derivation branch results,
# pick odds, ...) for the WP Debug node; see engine/modules/_detail.py.
_explain = bool(ctx.get(EXPLAIN_KEY))
# "Hold the value" base pass. A module on seed_scope=hold must resolve
# to its frame-0 (loop_index=0) value on EVERY iteration — INCLUDING
# nested @{} refs, which resolve_text bakes into the stored value.
# Re-resolving each iteration (the old seed-swap) failed for
# constrained wildcards (the constraint reshapes the pool per
# iteration) and for nested refs. So when a loop is driving
# (loop_index != 0) and any module holds, resolve the chain ONCE at
# loop_index=0 with the constant hold seed; held handlers copy their
# resolved value from this base context verbatim. The deepcopy keeps
# the throwaway pass's warnings/trace/constraint-state off the real
# ctx. loop_index=0 here means the recursive run skips its own base
# pass — no infinite recursion.
_any_hold = any(_module_seed_scope(m) == "hold" for m in modules)
if loop_index != 0 and _any_hold:
import copy as _copy
_hb = int(ctx.get("__wp_node_seed_hold__", 0) or 0)
base_ctx = PipelineEngine().run(
modules,
ctx=_copy.deepcopy(ctx),
seed=_hb,
hold_seed=_hb,
loop_index=0,
)
ctx["__wp_hold_base_ctx__"] = base_ctx
# Held wildcards copy their frozen VALUE from the base pass but
# early-return before `_record_pick` (wildcard_handler), so their
# frame-0 pick never lands in the real ctx. Downstream constraints
# then can't find the held source — they mis-fire (spurious
# source_missing / never_applied warnings) and, cross-node, leave a
# held target UNCONSTRAINED (the held-outfit drift: kimono at
# frame 0, unconstrained pick on frames 2+). Carry the base pass's
# pick + hit state for held modules into the real ctx HERE, before
# the per-module loop, so the registration is present for this
# node's constraints AND flows downstream (via __wp_picks__ /
# __wp_constraints__ carry-over) into the next node's base pass.
# The value-freeze path in the wildcard handler is untouched.
_base_picks = base_ctx.get("__wp_picks__")
if isinstance(_base_picks, dict):
_real_picks = ctx.setdefault("__wp_picks__", {})
if isinstance(_real_picks, dict):
for _m in modules:
if _module_seed_scope(_m) != "hold":
continue
_mid = (
_m.get("id", "") if isinstance(_m, dict)
else getattr(_m, "id", "")
)
if _mid and _mid in _base_picks and _mid not in _real_picks:
_real_picks[_mid] = _base_picks[_mid]
# Same carry-forward for rolled axes, but keyed by BINDING rather
# than module id — that is the namespace `$outfit.SHOES` addresses.
# Restricted to held modules, and `not in` rather than assignment,
# so a wildcard that never asked to hold still re-rolls this
# iteration.
# The declared-axes table travels with the rolls it describes.
for _table in ("__wp_axes__", "__wp_axis_decl__"):
_base_axes = base_ctx.get(_table)
if not isinstance(_base_axes, dict):
continue
_real_axes = ctx.setdefault(_table, {})
if not isinstance(_real_axes, dict):
continue
for _m in modules:
if _module_seed_scope(_m) != "hold":
continue
_binding = _module_binding(_m)
if (
_binding
and _binding in _base_axes
and _binding not in _real_axes
):
_real_axes[_binding] = _base_axes[_binding]
_base_hits = base_ctx.get("__wp_constraint_hits__")
if isinstance(_base_hits, dict):
_real_hits = ctx.setdefault("__wp_constraint_hits__", {})
if isinstance(_real_hits, dict):
for _cid, _n in _base_hits.items():
_real_hits[_cid] = max(
int(_real_hits.get(_cid, 0) or 0), int(_n or 0)
)
for index, module in enumerate(modules):
# Normalise id/type/enabled reads to work for both dicts and objects.
@@ -296,21 +476,63 @@ 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.
_warn_mark = len(ctx["__wp_warnings__"])
if _explain:
_module_name = _display_name(module, _module_name)
ctx[DETAIL_KEY] = {}
_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,
**(_finish_module(
ctx, _warn_mark, _module_id, _module_uid, _module_name,
) if _explain else {}),
})
continue
@@ -325,17 +547,43 @@ 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}
# Sweep mode (WP_ContextLoop): pin a swept wildcard to this
# frame's combination. Applied after the per-frame override so
# the sweep decides which option fires.
if isinstance(snapshot, dict):
snapshot = apply_pin_override(snapshot, _module_uid, ctx)
except Exception as e:
logger.warning(
"Failed to coerce module %r at index %s: %s", module, index, e
@@ -350,6 +598,9 @@ class PipelineEngine:
"writes": [],
"error": {"type": type(e).__name__, "message": str(e)},
**meta,
**(_finish_module(
ctx, _warn_mark, _module_id, _module_uid, _module_name,
) if _explain else {}),
})
continue
@@ -384,6 +635,7 @@ class PipelineEngine:
# binding can match a constraint to its own bundle copy's pick.
ctx["__wp_current_module_bundle_origin__"] = _module_bundle_origin
meta = _extract_static_meta(module)
ctx.pop(negatives.TOUCHED_KEY, None)
try:
bindings = resolve_module(snapshot, ctx)
except UnknownModuleType:
@@ -401,6 +653,9 @@ class PipelineEngine:
"writes": [],
"error": None,
**meta,
**(_finish_module(
ctx, _warn_mark, _module_id, _module_uid, _module_name,
) if _explain else {}),
})
continue
except Exception as e:
@@ -432,6 +687,9 @@ class PipelineEngine:
"writes": [],
"error": {"type": type(e).__name__, "message": str(e)},
**meta,
**(_finish_module(
ctx, _warn_mark, _module_id, _module_uid, _module_name,
) if _explain else {}),
})
continue
@@ -449,15 +707,37 @@ class PipelineEngine:
key = var.lstrip("$")
before = ctx.get(key)
ctx[key] = value
# Last-write-wins applies to the internal flag too: whichever
# module writes the var LAST decides whether it is internal. A
# public writer overriding an earlier internal one MUST clear
# the flag — otherwise the var stays hidden from the assembler
# forever, even though its final value came from a public
# module. (Mark $outfit internal, then override it with a
# public $outfit: the override should be visible.)
flags = ctx.setdefault("__wp_internal_flags__", {})
if mark_internal:
flags = ctx.setdefault("__wp_internal_flags__", {})
flags[key] = True
else:
flags.pop(key, None)
writes.append({
"variable": key,
"value": value,
"source": module_type,
"overwrite": before is not None and before != value,
})
# A write replaces the value, so it replaces what the value
# carried: bindings the handler gave no negatives lose theirs.
negatives.settle_writes(
ctx, [v.lstrip("$") for v in (bindings or {})],
)
# WP Debug's trace shows what each write now carries.
for w in writes:
texts = [
str(e.get("text", ""))
for e in negatives.get_entries(ctx, w["variable"])
]
if texts:
w["negative"] = ", ".join(texts)
# `seed` on the trace entry: the effective seed THIS
# module rolled with — `instance.locked_seed` if locked,
@@ -480,6 +760,9 @@ class PipelineEngine:
"error": None,
"seed": effective_seed,
**meta,
**(_finish_module(
ctx, _warn_mark, _module_id, _module_uid, _module_name,
) if _explain else {}),
})
# Drop the active-module markers so they don't leak into the
@@ -490,6 +773,7 @@ class PipelineEngine:
ctx.pop("__wp_current_module_uid__", None)
ctx.pop("__wp_current_module_name__", None)
ctx.pop("__wp_current_module_bundle_origin__", None)
ctx.pop(DETAIL_KEY, None)
# SP3 reach selector finalisation: emit `constraint_never_applied`
# for every registered constraint whose selector covered ZERO
@@ -613,6 +897,44 @@ class PipelineEngine:
# otherwise render as plain text).
"message": message,
})
# Source-side companion warning. The `never_applied` message
# above is target-focused; when the constraint's SOURCE
# wildcard is ALSO absent from the chain, surface that
# distinctly so the Debug viewer shows BOTH ends (mirrors the
# editor's separate "source missing" / "target missing" row
# badges). A constraint registers by its own module position,
# so it can sit in the bucket with neither end present.
source_uuid = c.get("source_wildcard_id", "")
if source_uuid and not _target_present_in_chain(
source_uuid, modules, catalog,
):
src_row = (
catalog.get(source_uuid) if isinstance(catalog, dict) else None
)
source_name = (
(src_row.get("name", "") or "")
if isinstance(src_row, dict) else ""
)
src_suffix = _build_ref_name_suffix(source_name)
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
if isinstance(warnings_bucket, list):
warnings_bucket.append({
"type": "constraint_source_missing",
"severity": "warn",
"module_id": cid,
"source_field": "",
"position": 0,
"token_index": None,
"detail": {
"constraint_id": cid,
"source_wildcard_id": source_uuid,
},
"message": (
f"constraint @{{{library_cid}{cid_suffix}}} did not apply — "
f"its source @{{{source_uuid}{src_suffix}}} wildcard isn't in "
f"this chain, so it never registers a source pick."
),
})
# SP3 partial-reach finalisation: a constraint that DID fire but
# whose `next N` / `pick` selector matched FEWER occurrences than
+195
View File
@@ -0,0 +1,195 @@
"""Server-side user preferences (`settings.json`).
Why a file and not the database: some of these settings govern the database
itself (backups run before migrations and restores replace the DB file), so
they must be readable before any connection opens — the same chicken-and-egg
that put the DB location preference in `engine/db/config.py`'s sidecar.
Why not the browser: the ref recursion limit applies to canvas runs, preview
and the Test Runner alike, all of which execute server-side. A value living
in localStorage would only reach whichever surface happened to send it.
Location, highest priority first:
1. ``WP_SETTINGS_PATH`` env var — used as-is (tests point it at tmp_path).
2. ``<ComfyUI user dir>/wildcard-pipeline/settings.json`` — beside the
library, so it survives an extension reinstall.
3. ``<plugin>/wp-settings.json`` — when the user dir can't be detected.
Every read normalizes: a missing file, unreadable JSON or an out-of-range
value all degrade to defaults instead of raising, because a broken settings
file must never stop a prompt from rendering.
"""
from __future__ import annotations
import copy
import json
import os
import tempfile
import threading
from pathlib import Path
from typing import Any
SETTINGS_FILENAME = "settings.json"
FALLBACK_FILENAME = "wp-settings.json"
MAX_REF_DEPTH_MIN = 1
MAX_REF_DEPTH_MAX = 32
BACKUP_KEEP_MIN = 1
BACKUP_KEEP_MAX = 100
DEFAULTS: dict[str, Any] = {
"max_ref_depth": 8,
"backups": {
"enabled": True,
"keep": 7,
"daily": True,
},
}
_lock = threading.Lock()
#: ``(path, mtime_ns)`` → cached normalized prefs. `max_ref_depth()` runs once
#: per pipeline run (every Context node execution, every Test Runner seed), so
#: a stat beats a parse.
_cache: tuple[tuple[str, int], dict[str, Any]] | None = None
def settings_path() -> Path:
"""Resolve where settings live (see module docstring for the order)."""
override = os.environ.get("WP_SETTINGS_PATH")
if override:
return Path(override)
from engine.db.connection import wp_data_dir
data_dir = wp_data_dir()
if data_dir is not None:
return data_dir / SETTINGS_FILENAME
from engine.db.config import plugin_root
return plugin_root() / FALLBACK_FILENAME
def _clamp_int(value: Any, lo: int, hi: int, default: int) -> int:
"""Clamp an int into range; non-ints (bools included) fall back to default.
Clamping rather than rejecting an out-of-range number: a user who typed 50
meant "as deep as allowed", not "reset to 8".
"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return default
if isinstance(value, float) and not value.is_integer():
return default
return max(lo, min(hi, int(value)))
def _bool(value: Any, default: bool) -> bool:
return value if isinstance(value, bool) else default
def normalize(raw: Any) -> dict[str, Any]:
"""Return the full prefs shape from any input, filling/repairing fields."""
src = raw if isinstance(raw, dict) else {}
backups_raw = src.get("backups")
backups_src = backups_raw if isinstance(backups_raw, dict) else {}
d_backups = DEFAULTS["backups"]
return {
"max_ref_depth": _clamp_int(
src.get("max_ref_depth"), MAX_REF_DEPTH_MIN, MAX_REF_DEPTH_MAX,
DEFAULTS["max_ref_depth"],
),
"backups": {
"enabled": _bool(backups_src.get("enabled"), d_backups["enabled"]),
"keep": _clamp_int(
backups_src.get("keep"), BACKUP_KEEP_MIN, BACKUP_KEEP_MAX,
d_backups["keep"],
),
"daily": _bool(backups_src.get("daily"), d_backups["daily"]),
},
}
def _read_raw(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _stat_key(path: Path) -> tuple[str, int]:
try:
return (str(path), path.stat().st_mtime_ns)
except OSError:
return (str(path), -1)
def load() -> dict[str, Any]:
"""Current prefs, normalized. Cached on (path, mtime) so repeat calls are
a single ``stat``; an external edit to the file is picked up next call."""
global _cache
path = settings_path()
key = _stat_key(path)
with _lock:
if _cache is not None and _cache[0] == key:
return copy.deepcopy(_cache[1])
prefs = normalize(_read_raw(path))
with _lock:
_cache = (key, prefs)
return copy.deepcopy(prefs)
def _deep_merge(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
out = dict(base)
for k, v in patch.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = _deep_merge(out[k], v)
else:
out[k] = v
return out
def _save(prefs: dict[str, Any], path: Path) -> None:
"""Atomically write (temp file + rename) so a crash can't truncate it."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_str = tempfile.mkstemp(prefix=".wp-settings-", dir=str(path.parent))
tmp_path = Path(tmp_str)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(prefs, f, indent=2)
os.replace(tmp_path, path)
except OSError:
tmp_path.unlink(missing_ok=True)
raise
def update(partial: dict[str, Any]) -> dict[str, Any]:
"""Deep-merge ``partial`` over the current prefs, normalize, persist.
Unknown keys are dropped by `normalize`, so the file only ever holds the
documented shape. Returns the full normalized prefs.
"""
global _cache
if not isinstance(partial, dict):
raise TypeError("prefs update must be a dict")
path = settings_path()
merged = normalize(_deep_merge(load(), partial))
_save(merged, path)
with _lock:
_cache = None
return copy.deepcopy(merged)
def max_ref_depth() -> int:
"""The configured ``@{uuid}`` nesting limit — cheap enough per run.
Never raises: a settings problem must degrade to the default limit, not
fail the prompt that asked for it.
"""
try:
return int(load()["max_ref_depth"])
except Exception: # noqa: BLE001 - see docstring
return int(DEFAULTS["max_ref_depth"])
def backups() -> dict[str, Any]:
"""The ``backups`` sub-section of the current prefs."""
return load()["backups"]
+397
View File
@@ -0,0 +1,397 @@
"""Scenario runs — the Test Runner's engine side.
A scenario is an ordered module stack (the same list shape a WP_Context node
feeds ``PipelineEngine.run``), optional pinned ``$var`` values standing in for
whatever an upstream node would pass in, and a set of seeds. Running it means
one real ``PipelineEngine.run`` per seed and a summary of what came out:
- per-variable value counts (fully expanded values, so ``{a|b}`` branches
and ``@{ref}`` expansions are counted separately),
- per-wildcard option pick counts (by option id, before expansion),
- per-constraint downstream hit totals,
- engine warnings grouped by (type, message) with the seeds they fired on,
- the first ``sample_limit`` samples in full, each with its trace and
each variable's negatives (send-to-negative),
- optionally, the rendered value of a few ``track`` variables for each of
the first ``track_limit`` seeds (what a stored baseline compares against).
Every seed is a real chain seed: the same seed on a canvas Context node holding
the same modules produces the same values. That is the point of running here
instead of in a browser-side mirror of the engine.
Pure Python, no ComfyUI or DB imports — the API layer resolves library rows
into the module list + catalog and hands them in.
"""
from __future__ import annotations
import copy
import random
import time
from collections import Counter
from typing import Any
from engine import negatives as neg
from engine.context import strip_engine_internals
from engine.pipeline import PipelineEngine
from engine.syntax.types import ListVar
MAX_SEEDS = 10_000
DEFAULT_SAMPLE_LIMIT = 200
DEFAULT_VALUE_LIMIT = 500
# Seeds that survive a round-trip through JavaScript numbers (2**53 - 1).
_MAX_SEED = 2**53 - 1
_WARNING_SEED_LIMIT = 20
MAX_TRACK_VARS = 8
DEFAULT_TRACK_LIMIT = 1000
MAX_TRACK_LIMIT = 2000
class ScenarioError(ValueError):
"""A scenario request that can't run (bad seed spec, too many seeds)."""
def resolve_seeds(spec: Any, *, rng: random.Random | None = None) -> list[int]:
"""Turn a seed spec into the concrete list of chain seeds.
Accepted shapes:
- ``{"from": S, "count": N}`` → ``S, S+1, … S+N-1``
- ``{"random": true, "count": N}`` → N distinct random seeds
- ``{"list": [s1, s2, …]}`` → exactly those seeds, in order
"""
if not isinstance(spec, dict):
raise ScenarioError("seeds must be an object")
if "list" in spec:
raw = spec["list"]
if not isinstance(raw, list) or not raw:
raise ScenarioError("seeds.list must be a non-empty list")
try:
seeds = [int(s) for s in raw]
except (TypeError, ValueError) as exc:
raise ScenarioError("seeds.list must hold integers") from exc
else:
try:
count = int(spec.get("count", 0))
except (TypeError, ValueError) as exc:
raise ScenarioError("seeds.count must be an integer") from exc
if count < 1:
raise ScenarioError("seeds.count must be at least 1")
if count > MAX_SEEDS:
raise ScenarioError(f"seeds.count must be at most {MAX_SEEDS}")
if spec.get("random"):
r = rng or random.SystemRandom()
picked: set[int] = set()
seeds = []
while len(seeds) < count:
s = r.randint(0, _MAX_SEED)
if s not in picked:
picked.add(s)
seeds.append(s)
else:
try:
start = int(spec.get("from", 0))
except (TypeError, ValueError) as exc:
raise ScenarioError("seeds.from must be an integer") from exc
seeds = list(range(start, start + count))
if len(seeds) > MAX_SEEDS:
raise ScenarioError(f"at most {MAX_SEEDS} seeds per run")
for s in seeds:
if s < 0 or s > _MAX_SEED:
raise ScenarioError(f"seed {s} is outside 0..{_MAX_SEED}")
return seeds
def _render(value: Any) -> str:
"""The string a value renders as in a prompt (a multi-pick joins)."""
if isinstance(value, ListVar):
return str(value)
return value if isinstance(value, str) else str(value)
def _json_value(value: Any) -> Any:
"""JSON-safe form of a resolved value; a multi-pick keeps its items so a
UI can index it the way ``$name.K`` does."""
if isinstance(value, ListVar):
return {"items": list(value.items), "sep": value.sep}
return value if isinstance(value, (str, int, float, bool)) or value is None else str(value)
def _sample_negatives(ctx: Any, names: Any) -> dict[str, list[dict[str, Any]]]:
"""Each public variable's negative entries (send-to-negative), as the
engine filed them: ``{binding: [{text, pick, source}]}``. Variables
without negatives are left out."""
out: dict[str, list[dict[str, Any]]] = {}
for name in names:
rows = [
{
"text": str(e.get("text", "")),
"pick": e.get("pick"),
"source": str(e.get("source") or ""),
}
for e in neg.get_entries(ctx, name)
if str(e.get("text", "")).strip()
]
if rows:
out[name] = rows
return out
def _joined_negative(ctx: Any, name: str) -> str:
"""A variable's negatives as the one line an Assembler rendering only
``$name`` would emit (tag-level dedupe)."""
return neg.join_unique(str(e.get("text", "")) for e in neg.get_entries(ctx, name))
def _refs_by_owner(log: Any) -> dict[str, list[dict[str, Any]]]:
"""Group the engine's nested-ref log by the stack uid whose resolve made
each pick, keeping pre-order so `depth` draws the tree."""
grouped: dict[str, list[dict[str, Any]]] = {}
if not isinstance(log, list):
return grouped
for row in log:
if not isinstance(row, dict):
continue
grouped.setdefault(str(row.get("owner") or ""), []).append({
"uuid": row.get("uuid", ""),
"name": row.get("name", ""),
"option_id": row.get("option_id"),
"depth": int(row.get("depth") or 0),
"value": _json_value(row.get("value", row.get("raw", ""))),
})
return grouped
def _slim_trace(
trace: Any, names: dict[str, str], refs: dict[str, list[dict[str, Any]]] | None = None,
) -> list[dict[str, Any]]:
"""The engine trace, trimmed to what a per-seed view needs. The engine
doesn't carry display names on trace rows, so they come from the stack
(keyed by `_uid`, falling back to the library id). ``refs`` adds the
nested `@{ref}` picks each row's resolve made, as a pre-order list."""
refs = refs or {}
out: list[dict[str, Any]] = []
if not isinstance(trace, list):
return out
for row in trace:
if not isinstance(row, dict):
continue
writes = []
for w in row.get("writes") or []:
if isinstance(w, dict):
write = {
"variable": w.get("variable"),
"value": _json_value(w.get("value")),
"overwrite": bool(w.get("overwrite", False)),
}
# The negatives the variable carries after this write (the
# engine only stamps it when non-empty).
if isinstance(w.get("negative"), str) and w["negative"]:
write["negative"] = w["negative"]
writes.append(write)
out.append({
"id": row.get("id", ""),
"_uid": row.get("_uid", ""),
"type": row.get("type", ""),
"name": names.get(row.get("_uid") or "") or names.get(row.get("id") or "", ""),
"binding": row.get("binding") or "",
"status": row.get("status", ""),
"seed": row.get("seed"),
"error": row.get("error"),
"writes": writes,
"refs": refs.get(str(row.get("_uid") or ""), []),
})
return out
def _slim_warning(w: Any) -> dict[str, Any]:
if not isinstance(w, dict):
return {"type": "unknown", "message": str(w)}
return {k: _json_value(v) for k, v in w.items()}
def _option_id_by_value(modules: list[dict[str, Any]]) -> dict[str, dict[str, str]]:
"""module id → {option value → option id}, for counting multi-pick
records (which carry values, not ids) against their options."""
table: dict[str, dict[str, str]] = {}
for m in modules:
if not isinstance(m, dict) or m.get("type") != "wildcard":
continue
payload = m.get("payload")
options = payload.get("options") if isinstance(payload, dict) else None
if not isinstance(options, list):
continue
by_value: dict[str, str] = {}
for opt in options:
if isinstance(opt, dict) and isinstance(opt.get("id"), str):
by_value.setdefault(str(opt.get("value", "")), opt["id"])
table[str(m.get("id", ""))] = by_value
return table
def run_scenario(
modules: list[dict[str, Any]],
*,
seeds: list[int],
catalog: dict[str, Any] | None = None,
pins: dict[str, str] | None = None,
sample_limit: int = DEFAULT_SAMPLE_LIMIT,
value_limit: int = DEFAULT_VALUE_LIMIT,
track: list[str] | None = None,
track_limit: int = DEFAULT_TRACK_LIMIT,
) -> dict[str, Any]:
"""Run ``modules`` once per seed and summarise the results.
``pins`` are written into the context before each run, exactly like
values arriving from an upstream Context node; a module in the stack that
writes the same variable replaces the pin, as it would at run time.
A seed whose run raises is recorded as a failed sample (and counted in
``failed``) rather than aborting the whole scenario.
``track`` names variables whose rendered value is recorded per seed, for
the first ``track_limit`` seeds, under ``tracked``: ``{"seeds": [...],
"values": {name: [value or None, ...]}}``. ``None`` means the variable
wasn't set on that seed (or the seed failed).
"""
catalog = catalog or {}
pins = {str(k): str(v) for k, v in (pins or {}).items()}
option_ids = _option_id_by_value(modules)
names: dict[str, str] = {}
for m in modules:
if isinstance(m, dict):
meta = m.get("meta") if isinstance(m.get("meta"), dict) else {}
name = str(m.get("name") or meta.get("name") or "")
for key in (m.get("_uid"), m.get("id")):
if isinstance(key, str) and key:
names.setdefault(key, name)
var_counts: dict[str, Counter[str]] = {}
pick_counts: dict[str, Counter[str]] = {}
constraint_hits: Counter[str] = Counter()
warnings: dict[tuple[str, str], dict[str, Any]] = {}
internal_vars: set[str] = set()
samples: list[dict[str, Any]] = []
failed = 0
track = [str(t).lstrip("$") for t in (track or [])][:MAX_TRACK_VARS]
tracked_seeds: list[int] = []
tracked: dict[str, list[str | None]] = {name: [] for name in track}
tracked_neg: dict[str, list[str | None]] = {name: [] for name in track}
def record(seed: int, resolved: dict[str, Any] | None, run_ctx: Any = None) -> None:
if not track or len(tracked_seeds) >= track_limit:
return
tracked_seeds.append(seed)
for name in track:
if resolved is None or name not in resolved:
tracked[name].append(None)
tracked_neg[name].append(None)
else:
tracked[name].append(_render(resolved[name]))
tracked_neg[name].append(_joined_negative(run_ctx, name))
started = time.perf_counter()
for seed in seeds:
ctx: dict[str, Any] = dict(pins)
ctx["__wp_catalog__"] = catalog
# Ask the resolver to record nested @{ref} picks for the trace.
ctx["__wp_ref_log__"] = []
try:
# The engine mutates module dicts in places (coercion, per-run
# stamps); a deep copy keeps every seed starting from the same
# stack.
ctx = PipelineEngine().run(copy.deepcopy(modules), ctx=ctx, seed=seed)
except Exception as exc: # noqa: BLE001 - one bad seed must not sink the run
failed += 1
record(seed, None)
if len(samples) < sample_limit:
samples.append({
"seed": seed, "vars": {}, "trace": [], "warnings": [],
"negatives": {},
"error": f"{type(exc).__name__}: {exc}",
})
continue
resolved = strip_engine_internals(ctx)
record(seed, resolved, ctx)
flags = ctx.get("__wp_internal_flags__")
if isinstance(flags, dict):
internal_vars.update(k for k, v in flags.items() if v)
for name, value in resolved.items():
var_counts.setdefault(name, Counter())[_render(value)] += 1
picks = ctx.get("__wp_picks__")
if isinstance(picks, dict):
for mid, entry in picks.items():
if not isinstance(entry, dict):
continue
bucket = pick_counts.setdefault(str(mid), Counter())
if isinstance(entry.get("values"), list):
lookup = option_ids.get(str(mid), {})
for v in entry["values"]:
bucket[lookup.get(str(v), str(v))] += 1
elif entry.get("id") is not None:
bucket[str(entry["id"])] += 1
# Nested @{ref} picks count toward their wildcard's option counts
# too, so a wildcard reached only through refs still shows up.
ref_log = ctx.get("__wp_ref_log__")
if isinstance(ref_log, list):
for row in ref_log:
if isinstance(row, dict) and row.get("uuid"):
oid = row.get("option_id")
key = str(oid) if oid is not None else str(row.get("raw", ""))
pick_counts.setdefault(str(row["uuid"]), Counter())[key] += 1
hits = ctx.get("__wp_constraint_hits__")
if isinstance(hits, dict):
for cid, n in hits.items():
constraint_hits[str(cid)] += int(n or 0)
sample_warnings = [_slim_warning(w) for w in ctx.get("__wp_warnings__") or []]
for w in sample_warnings:
key = (str(w.get("type", "")), str(w.get("message", "")))
group = warnings.get(key)
if group is None:
group = warnings[key] = {**w, "count": 0, "seeds": []}
group["count"] += 1
if seed not in group["seeds"] and len(group["seeds"]) < _WARNING_SEED_LIMIT:
group["seeds"].append(seed)
if len(samples) < sample_limit:
samples.append({
"seed": seed,
"vars": {k: _json_value(v) for k, v in resolved.items()},
"trace": _slim_trace(
ctx.get("__wp_trace__"), names, _refs_by_owner(ctx.get("__wp_ref_log__")),
),
"warnings": sample_warnings,
"negatives": _sample_negatives(ctx, resolved.keys()),
"error": None,
})
elapsed_ms = round((time.perf_counter() - started) * 1000, 1)
variables: dict[str, Any] = {}
for name, counts in var_counts.items():
ranked = counts.most_common()
kept = ranked[:value_limit]
variables[name] = {
"counts": dict(kept),
"distinct": len(ranked),
"other": sum(n for _, n in ranked[value_limit:]),
"internal": name in internal_vars,
}
return {
"runs": len(seeds),
"failed": failed,
"elapsed_ms": elapsed_ms,
"seeds": {"first": seeds[0] if seeds else None, "count": len(seeds)},
"variables": variables,
"picks": {mid: dict(c) for mid, c in pick_counts.items()},
"constraint_hits": dict(constraint_hits),
"warnings": sorted(warnings.values(), key=lambda g: -g["count"]),
"samples": samples,
"tracked": (
{"seeds": tracked_seeds, "values": tracked, "negatives": tracked_neg}
if track else None
),
}
+12
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
@@ -47,6 +51,10 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
# SP2c: multi-pick draws WITH replacement (repeats allowed) when true,
# without (unique) when false/absent. Mirrors the inline `~` flag.
"pick_independent": "boolean",
# Model-aware pipelines: take the option whose text equals this
# variable's value (e.g. `model_variant`), else the fallback, else
# roll. Instance-local; makes the wildcard a constraint source.
"match_variable": "string",
# `mode` and `pinned_option_id` removed in v2 — resolve mode is
# implicit in pool state. Engine handler still reads them when
# present in legacy snapshots; the schema validator now flags
@@ -59,6 +67,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 +79,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": {
+23 -3
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,
@@ -53,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
@@ -64,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")
+122
View File
@@ -0,0 +1,122 @@
"""Sweep mode for WP_ContextLoop: every combination of chosen wildcards.
A sweep axis names one downstream wildcard instance (by its per-instance
`_uid`) and the option ids to walk. The loop runs the cartesian product of
the axes, one frame per combination, capped at `limit`. Each frame carries a
`{uid: option_id}` pin map in `__wp_pin_overrides__`; the pipeline turns a
matching wildcard instance into a `pinned` one for that frame (see
`apply_pin_override`), so the existing pinned path does the picking.
Ordering is row-major: the LAST axis changes fastest, so a two-axis sweep
reads like a grid (row = first axis, column = second).
Pure Python, no ComfyUI imports.
"""
from __future__ import annotations
import itertools
from typing import Any
PIN_KEY = "__wp_pin_overrides__"
HOLD_KEY = "__wp_sweep_hold__"
DEFAULT_LIMIT = 64
MAX_LIMIT = 999
def parse_sweep(raw: object) -> dict[str, Any]:
"""Recovery-friendly parse of the loop config's `sweep` block.
Axes without a uid or without any option ids are dropped; a uid listed
twice keeps its first axis; option ids are deduped in order. Mirrors
`parseSweep` in `src/components/context-loop/types.ts`.
"""
out: dict[str, Any] = {
"enabled": False,
"limit": DEFAULT_LIMIT,
"hold_others": True,
"axes": [],
}
if not isinstance(raw, dict):
return out
out["enabled"] = raw.get("enabled") is True
limit = raw.get("limit")
if isinstance(limit, int) and not isinstance(limit, bool):
out["limit"] = min(MAX_LIMIT, max(1, limit))
hold = raw.get("hold_others")
if isinstance(hold, bool):
out["hold_others"] = hold
axes: list[dict[str, Any]] = []
seen: set[str] = set()
for axis in raw.get("axes") or []:
if not isinstance(axis, dict):
continue
uid = axis.get("uid")
if not isinstance(uid, str) or not uid or uid in seen:
continue
ids: list[str] = []
for oid in axis.get("option_ids") or []:
if isinstance(oid, str) and oid and oid not in ids:
ids.append(oid)
# An axis with nothing ticked is kept (the user cleared it to pick
# a few by hand) but sweeps nothing: see `live_axes`.
seen.add(uid)
clean: dict[str, Any] = {"uid": uid, "option_ids": ids}
label = axis.get("label")
if isinstance(label, str) and label:
clean["label"] = label
axes.append(clean)
out["axes"] = axes
return out
def live_axes(axes: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""The axes with at least one option ticked; only these sweep."""
return [a for a in axes if a["option_ids"]]
def sweep_total(axes: list[dict[str, Any]]) -> int:
"""Number of combinations before the limit (1 for no axes)."""
total = 1
for axis in live_axes(axes):
total *= len(axis["option_ids"])
return total
def sweep_frames(axes: list[dict[str, Any]], limit: int) -> list[dict[str, str]]:
"""The first `limit` combinations as `{uid: option_id}` pin maps."""
axes = live_axes(axes)
if not axes:
return []
uids = [a["uid"] for a in axes]
combos = itertools.product(*(a["option_ids"] for a in axes))
capped = itertools.islice(combos, max(1, limit))
return [dict(zip(uids, combo, strict=True)) for combo in capped]
def apply_pin_override(
snapshot: dict[str, Any], module_uid: str, ctx: dict[str, Any],
) -> dict[str, Any]:
"""Return `snapshot` with a swept wildcard pinned to this frame's option.
Only wildcards are pinned; the pin rides the existing `pinned` mode so
axes still roll and the pick is recorded for downstream constraints.
Returns a shallow copy rather than editing in place, because a snapshot
can be the caller's module dict itself (`coerce_legacy_module` passes
already-coerced modules through).
"""
pins = ctx.get(PIN_KEY)
if not isinstance(pins, dict) or not module_uid or module_uid not in pins:
return snapshot
if snapshot.get("type") != "wildcard":
return snapshot
inst = snapshot.get("instance")
return {
**snapshot,
"instance": {
**(inst if isinstance(inst, dict) else {}),
"mode": "pinned",
"pinned_option_id": pins[module_uid],
},
}
+316 -19
View File
@@ -7,6 +7,7 @@ happens inside `_resolve_ref` (Task 9) and brace resolvers (Tasks 10–11).
from __future__ import annotations
import logging
from typing import Any
from engine.syntax.subcat_filter import matches as _subcat_matches
from engine.syntax.subcat_filter import parse as _parse_subcat
@@ -42,6 +43,7 @@ def _resolve_tokens(
visited: tuple[str, ...],
) -> str:
parts: list[str] = []
empty_refs: list[int] = []
for tok in tokens:
if tok.kind == TokenKind.TEXT:
parts.append(tok.raw)
@@ -50,7 +52,10 @@ def _resolve_tokens(
elif tok.kind == TokenKind.VAR:
parts.append(_resolve_var(tok, ctx))
elif tok.kind == TokenKind.REF:
parts.append(_resolve_ref(tok, ctx, depth, visited))
resolved = _resolve_ref(tok, ctx, depth, visited)
if not resolved:
empty_refs.append(len(parts))
parts.append(resolved)
elif tok.kind == TokenKind.DP_BRACE:
parts.append(_resolve_inline_pick(tok, ctx, depth, visited))
elif tok.kind == TokenKind.DP_MULTI:
@@ -62,7 +67,52 @@ def _resolve_tokens(
raise AssertionError("DP_PIPE should never be top-level")
else:
raise AssertionError(f"unknown token kind: {tok.kind}")
return "".join(parts)
return _join_tidy(parts, empty_refs)
_HWS = " \t"
def _join_tidy(parts: list[str], empty_refs: list[int]) -> str:
"""Join resolved parts, closing the gap each empty ``@{}`` ref leaves.
A ref that resolves to nothing (a placeholder or deleted module, a filter
that matched no option, an empty option) used to leave its surroundings
behind: ``red @{x} dress`` became ``red dress`` and ``red, @{x}, dress``
became ``red, , dress``. At each such seam this drops one doubled comma,
then one side of doubled spaces/tabs, and at the start or end of the text
the dangling comma and spaces. Newlines and everything away from the seam
are left exactly as written. Mirrored in ``resolveTokens.ts``.
"""
if not empty_refs:
return "".join(parts)
out = list(parts)
for i in empty_refs:
left = next((j for j in range(i - 1, -1, -1) if out[j]), None)
right = next((j for j in range(i + 1, len(out)) if out[j]), None)
if left is None and right is None:
continue
if left is None:
out[right] = _strip_lead(out[right])
continue
if right is None:
out[left] = _strip_trail(out[left])
continue
if out[left].rstrip(_HWS).endswith(",") and out[right].lstrip(_HWS).startswith(","):
out[right] = out[right].lstrip(_HWS)[1:]
if out[right] and out[left][-1:] in (" ", "\t") and out[right][0] in _HWS:
out[right] = out[right].lstrip(_HWS)
return "".join(out)
def _strip_lead(text: str) -> str:
text = text.lstrip(_HWS)
return text[1:].lstrip(_HWS) if text.startswith(",") else text
def _strip_trail(text: str) -> str:
text = text.rstrip(_HWS)
return text[:-1].rstrip(_HWS) if text.endswith(",") else text
_VAR_SURFACES_ALLOWED = frozenset(["combine", "derivation", "assembler"])
@@ -103,6 +153,20 @@ def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
),
)
return f"${name}"
# An axis read is a different lookup against a different bucket, so it
# branches BEFORE the var lookup — `$outfit.SHOES` must resolve even when
# nothing bound `$outfit` itself, and must not raise `unknown_var` on the
# way past.
axis = tok.meta.get("axis")
if axis == NEG_ACCESSOR:
# `$name.neg` reads the variable's negatives. Not a read of the
# variable itself: it carries no negatives along (that would echo
# them) and does not count as rendering `$name`.
return _resolve_negative(tok, ctx)
if axis:
out = _resolve_axis(tok, ctx, str(axis))
_note_read(ctx, name, tok.meta.get("index"), out)
return out
value = ctx.get_var(name)
if value is None:
_push_warning(
@@ -122,7 +186,132 @@ def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
# as a 1-element list so `$str.0` == `$str` and `$str.1` == "". The
# accessor + ListVar-fold contract lives in deref_var_value
# (engine/syntax/types.py); the derivation + converter reads share it.
return deref_var_value(value, tok.meta.get("index"))
out = deref_var_value(value, tok.meta.get("index"))
_note_read(ctx, name, tok.meta.get("index"), out)
return out
#: The accessor that reads a variable's negatives (`$name.neg`,
#: `$name.K.neg`). Reserved: an `accepts` tag group cannot use this name.
NEG_ACCESSOR = "neg"
def _resolve_negative(tok: Token, ctx: ResolveContext) -> str:
"""Render `$name.neg` / `$name.K.neg`: the negatives filed for `name`
(all of them, or pick K's plus the whole-value ones), tag-deduped.
A variable with no negatives renders empty without a warning, since most
variables have none. Only an unbound name that also has none warns."""
name = str(tok.meta.get("name", ""))
index = tok.meta.get("index")
getter = getattr(ctx, "get_negative", None)
got = getter(name, index if isinstance(index, int) else None) if callable(getter) else None
if got is None and ctx.get_var(name) is None:
_push_warning(
ctx,
type="unknown_var",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"name": name, "surface": ctx.surface},
message=f"Unknown variable ${name}",
)
return got or ""
def _note_read(ctx: ResolveContext, name: str, index: Any, out: str) -> None:
"""Tell a collecting context (send-to-negative) which binding this read
rendered, and which pick. An indexed read that rendered nothing (out of
range) carries nothing."""
note = getattr(ctx, "note_var_read", None)
if not callable(note):
return
if index is not None and not out:
return
note(name, index if isinstance(index, int) else None)
def _note_ref_negative(ctx: ResolveContext, option: dict | None) -> None:
note = getattr(ctx, "note_ref_negative", None)
if callable(note) and isinstance(option, dict):
note(option)
def _resolve_axis(tok: Token, ctx: ResolveContext, axis: str) -> str:
"""Render `$name.AXIS`, `$name.K.AXIS` and `$name.AXIS.K`.
Never re-rolls: the choice was made when the wildcard picked, and this
reads it back — that is what makes two modules, or two nodes, agree.
Deliberately does NOT route through `deref_var_value`. An axis value is not
a ListVar, and keeping it off that path leaves the SP2a accessor contract
untouched.
"""
name = str(tok.meta.get("name", ""))
got = ctx.get_axis(name, axis)
if got is None:
declared = ctx.declared_axes(name)
if declared is not None and axis in declared:
# The axis exists; this roll's option just carries no tag on it.
# Legitimate (the editor already advises on such options), so it
# is an info note, and asking whether the group is marked
# `accepts` would send the user to fix something that is fine.
_push_warning(
ctx,
type="axis_untagged_pick",
severity="info",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"name": name, "axis": axis, "surface": ctx.surface},
message=(
f"${name}.{axis} is empty: the picked ${name} option "
f"has no '{axis}' tag"
),
)
return ""
if declared:
hint = (
f"its wildcard's accepts groups are {', '.join(declared)}. "
f"Is '{axis}' a classify group, or misspelled?"
)
elif declared is not None:
hint = (
"its wildcard has no accepts groups. Is that tag group "
"marked 'accepts'?"
)
else:
hint = "is that tag group marked 'accepts'?"
_push_warning(
ctx,
type="unknown_tag_axis",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={
"name": name, "axis": axis, "surface": ctx.surface,
"declared": declared,
},
message=f"${name} has no '{axis}' axis — {hint}",
)
return ""
index = tok.meta.get("index")
if isinstance(got, list):
if index is None:
return ", ".join(g for g in got if g)
if isinstance(index, int) and 0 <= index < len(got):
return got[index] or ""
return ""
# A single pick behaves as a one-element list, mirroring deref_var_value so
# `$x.0.AXIS` == `$x.AXIS` and any higher index is empty.
if index is not None:
return str(got) if index == 0 else ""
return str(got)
def _push_warning(ctx: ResolveContext, **fields) -> None:
@@ -140,7 +329,13 @@ def _pick_weighted(options: list[dict], rng) -> dict | None:
return None
total = sum(max(0.0, float(o.get("weight", 1))) for o in options)
if total <= 0:
return options[0]
# Nothing is selectable — every option is either weight 0 (documented
# as "disable without deleting") or was excluded by a constraint.
# Returning options[0] here looked like a working pick: a real option,
# right shape, right place, and wrong. It stayed wrong silently until
# the list was reordered. Callers turn None into an empty binding,
# which is what the null option already means.
return None
r = rng.random() * total
acc = 0.0
for opt in options:
@@ -169,8 +364,19 @@ def _resolve_inline_pick(
branches: list[str] = tok.meta.get("branches", [])
if not branches:
return ""
chosen_idx = ctx.rng.randrange(len(branches))
chosen = branches[chosen_idx]
# Per-branch weights (`N::value`), same micro-syntax the multi-pick block
# honors. Strip the prefix off every branch so it never leaks into output;
# only switch to weighted selection when a branch actually carries a weight,
# so unweighted `{a|b|c}` keeps its byte-for-byte seed sequence (legacy
# randrange path — changing the draw would shift every existing seed).
values = [_strip_branch_weight(b) for b in branches]
has_weights = any(v != b for v, b in zip(values, branches, strict=True))
if has_weights:
weights = [_parse_branch_weight(b) for b in branches]
chosen_idx = _weighted_pick_index(weights, ctx.rng)
else:
chosen_idx = ctx.rng.randrange(len(branches))
chosen = values[chosen_idx]
if not chosen:
return ""
nested_tokens = tokenize_text(chosen)
@@ -298,13 +504,20 @@ def _resolve_multi_pick(
if callable(set_carrier):
set_carrier(ref_uuid, None)
try:
return sep.join(
_resolve_tokens(
parts: list[str] = []
id_of = {str(o.get("value", "")): o.get("id") for o in pool}
opt_of = {str(o.get("value", "")): o for o in pool}
for v in chosen:
_note_ref_negative(ctx, opt_of.get(v))
logged = _log_ref_pick(ctx, ref_uuid, id_of.get(v), depth, v)
out = _resolve_tokens(
tokenize_text(v), ctx, depth=depth + 1,
visited=visited + (ref_uuid,),
) if v else ""
for v in chosen
)
if logged is not None:
logged["value"] = out
parts.append(out)
return sep.join(parts)
finally:
if callable(set_carrier) and saved_carrier is not None:
set_carrier(*saved_carrier)
@@ -380,6 +593,7 @@ def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
filter_expr = tok.meta.get("filter_expr")
exclude_null = bool(tok.meta.get("exclude_null"))
if (isinstance(filter_expr, str) and filter_expr.strip()) or exclude_null:
pool_total = len(options)
ast = _parse_subcat(filter_expr) if isinstance(filter_expr, str) else None
options = [
o for o in options
@@ -393,6 +607,41 @@ def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
)
]
if not options:
# "Matched no options" on its own is true and useless — it cannot
# tell a wrong filter from a right filter aimed at a stale pool.
# Report WHICH pool was searched, how big it was, and what tags it
# actually offers, so the panel answers the question instead of
# restating the symptom.
available = sorted({
t
for o in (payload_dict.get("options", []) if isinstance(payload_dict, dict)
else module.get("options", []))
if isinstance(o, dict)
for t in (o.get("sub_categories") or [])
if isinstance(t, str)
})
pool_origin = module.get("pool_origin")
library_count = module.get("library_option_count")
detail = {
"uuid": uuid,
"filter_expr": filter_expr,
"exclude_null": exclude_null,
"pool_total": pool_total,
"available_sub_categories": available,
}
if pool_origin:
detail["pool_origin"] = pool_origin
if isinstance(library_count, int):
detail["library_option_count"] = library_count
where = (
"this node's copy" if pool_origin == "node"
else "the library" if pool_origin == "library"
else "the pool"
)
drift = (
f"; the library now has {library_count}"
if isinstance(library_count, int) else ""
)
_push_warning(
ctx,
type="ref_subcategory_empty_pool",
@@ -401,18 +650,23 @@ def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
source_field="",
position=tok.start,
token_index=None,
detail={
"uuid": uuid,
"filter_expr": filter_expr,
"exclude_null": exclude_null,
},
detail=detail,
message=(
f"@{{{uuid}:{filter_expr or ''}"
f"{'!null' if exclude_null else ''}}} matched no options"
f"{'!null' if exclude_null else ''}}} matched no options — "
f"searched {where} ({pool_total} option"
f"{'' if pool_total == 1 else 's'}){drift}"
),
)
return []
# Schema v9 fallback (engine/modules/_fallback.py): the flagged option
# rolls normally and returns as the whole pool when the constraints below
# leave nothing to pick.
from engine.modules._fallback import fallback_warning, find_fallback, pool_is_dead
fallback = find_fallback(options)
any_applied = False
get_constraints = getattr(ctx, "get_constraints", None)
get_picks = getattr(ctx, "get_picks", None)
if callable(get_constraints) and callable(get_picks):
@@ -422,6 +676,7 @@ def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
apply_constraints_for_target,
warn_excludes_all,
)
from engine.modules.wildcard_handler import _accepts_axes
# SP3: share the ctx-resident hit counter so a nested-ref
# target occurrence counts toward the same first/next
# coverage as direct top-level instances. `carrier_ctx` is
@@ -439,12 +694,42 @@ def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
options, any_applied = apply_constraints_for_target(
options, uuid, constraints, get_picks(), ctx.warnings,
hits=hits, firing_uid=None, carrier_ctx=carrier_ctx,
# TARGET-side accepts axes for a nested @{} target, so its
# multi-tag accepts option survives when the source allows any
# alternative (same as the top-level path).
target_axes=(
_accepts_axes(payload_dict)
if isinstance(payload_dict, dict) else {}
),
)
if any_applied:
if any_applied and fallback is None:
warn_excludes_all(options, uuid, ctx.warnings)
if fallback is not None and pool_is_dead(options):
ctx.warnings.append(fallback_warning(uuid, fallback, constrained=any_applied))
return [{**fallback, "weight": 1}]
return options
def _log_ref_pick(
ctx: ResolveContext, uuid: str, option_id: Any, depth: int, raw: str,
) -> dict[str, Any] | None:
"""Record one nested `@{uuid}` pick in the opt-in ref log, if the
context carries one (the Test Runner seeds it; canvas runs don't).
Returns the stored row so the caller can add the expanded `value`
after recursing, or None when nothing is being logged."""
log_ref = getattr(ctx, "log_ref", None)
if not callable(log_ref):
return None
module = ctx.get_module(uuid)
return log_ref({
"uuid": uuid,
"name": (module or {}).get("name") or uuid,
"option_id": option_id,
"depth": depth,
"raw": raw,
})
def _resolve_ref(
tok: Token,
ctx: ResolveContext,
@@ -525,6 +810,11 @@ def _resolve_ref(
if chosen is None:
return ""
# Send-to-negative: the picked option's negative belongs to whatever this
# text resolves into (the carrier binding). Noted even for an empty pick,
# so a "none" option can still say what it rules out.
_note_ref_negative(ctx, chosen)
chosen_value = str(chosen.get("value", ""))
if not chosen_value:
return ""
@@ -538,15 +828,22 @@ def _resolve_ref(
# that's the expected deeper-than-one-hop behaviour (positional
# first/next/all cover deeper nesting). Mirrors the rng save/restore.
nested_tokens = tokenize_text(chosen_value)
# Opt-in ref log (Test Runner): record this pick before recursing so
# the log reads in pre-order, then fill in what it expanded to.
logged = _log_ref_pick(ctx, uuid, chosen.get("id"), depth, chosen_value)
get_carrier = getattr(ctx, "get_carrier", None)
set_carrier = getattr(ctx, "set_carrier", None)
if callable(get_carrier) and callable(set_carrier):
saved_carrier = get_carrier()
set_carrier(uuid, chosen.get("id"))
try:
return _resolve_tokens(
out = _resolve_tokens(
nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,)
)
finally:
set_carrier(*saved_carrier)
return _resolve_tokens(nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,))
else:
out = _resolve_tokens(nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,))
if logged is not None:
logged["value"] = out
return out
+23 -6
View File
@@ -13,10 +13,21 @@ import re
from engine.syntax.types import Token, TokenKind
# `$name` or `$name.K` — identifier starts with letter or underscore, then
# word chars, with an optional `.K` list-index accessor (SP2a). Group 2 = the
# index digits when present.
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?")
# `$name` with an optional accessor pair: at most one pick index and at most
# one axis, in EITHER order (`$o.0.SHOES` == `$o.SHOES.0`). Two mirrored
# alternatives, so `.0.1` and `.SHOES.BELTS` simply do not match the second
# segment — the grammar rejects them without a validation pass, and the tail
# stays literal text.
# group 1 — name
# group 2 — index, written first ($o.0 / $o.0.SHOES)
# group 3 — axis after an index ($o.0.SHOES)
# group 4 — axis, written first ($o.SHOES / $o.SHOES.0)
# group 5 — index after an axis ($o.SHOES.0)
_VAR_RE = re.compile(
r"\$([A-Za-z_][A-Za-z0-9_]*)"
r"(?:\.(?:(\d+)(?:\.([A-Za-z_][A-Za-z0-9_]*))?"
r"|([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?))?"
)
# 4-segment ref grammar (fixed order): `@{8hex [#name] [:expr] [!null]}`.
# group 1 — uuid: exactly 8 lowercase hex chars.
# group 2 — optional `#name`: display label cached at write-time so a
@@ -186,8 +197,14 @@ def tokenize_text(text: str) -> list[Token]:
if m:
_flush_text(i)
var_meta: dict[str, object] = {"name": m.group(1)}
if m.group(2) is not None:
var_meta["index"] = int(m.group(2))
# Normalise both spellings here, so nothing downstream ever
# learns which order the user wrote.
_idx = m.group(2) or m.group(5)
_axis = m.group(3) or m.group(4)
if _idx is not None:
var_meta["index"] = int(_idx)
if _axis is not None:
var_meta["axis"] = _axis
out.append(Token(
kind=TokenKind.VAR,
raw=m.group(0),
+48
View File
@@ -93,6 +93,36 @@ def split_var_accessor(name: str) -> tuple[str, int | None]:
return (name, None)
_VAR_REF_RE = re.compile(
r"^([A-Za-z_][A-Za-z0-9_]*)"
r"(?:\.(?:(\d+)(?:\.([A-Za-z_][A-Za-z0-9_]*))?"
r"|([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?))?$"
)
def parse_var_reference(name: str) -> tuple[str, int | None, str | None]:
"""Split a bare var reference into ``(base, index, axis)``.
The string-path twin of the tokenizer's ``_VAR_RE`` — derivation conditions
store ``condition.var`` as text, not tokens. Kept separate from
:func:`split_var_accessor` so the SP2a list-accessor contract and its
existing callers are untouched.
Both accessor orders are accepted and normalise to the same triple:
``outfit.0.SHOES`` and ``outfit.SHOES.0`` are the same reference. A name
that matches nothing is returned whole with both accessors ``None``, so an
unusual ctx key resolves as itself rather than being truncated.
"""
if not isinstance(name, str):
return ("", None, None)
m = _VAR_REF_RE.match(name)
if not m:
return (name, None, None)
idx = m.group(2) or m.group(5)
axis = m.group(3) or m.group(4)
return (m.group(1), int(idx) if idx is not None else None, axis)
def deref_var_value(value: str | ListVar | None, index: int | None) -> str:
"""Render a resolved var value to a string, honoring an optional ``.K``
list accessor (SP2a). ListVar: bare -> ``sep.join(items)``; ``.K`` ->
@@ -134,6 +164,24 @@ class ResolveContext(Protocol):
binds a ``str``."""
...
def get_axis(self, name: str, axis: str) -> str | list[str | None] | None:
"""Return the tag rolled for `name`'s `axis`, or None if it has none.
A single-pick source yields a ``str``; a multi-select one yields a
list with one slot per pick in pick order (``None`` for a pick whose
option carries no tag on this axis, so indexes line up with the
variable's own). The value was rolled
when the wildcard picked — this only reads it back, which is what makes
two modules (or two nodes) agree.
"""
...
def declared_axes(self, name: str) -> list[str] | None:
"""Return the ``accepts`` axes the wildcard bound to `name` declares,
or None when that is not known. Lets an empty axis read tell "this
pick carries no tag on the axis" apart from "there is no such axis"."""
...
def get_module(self, uuid: str) -> dict[str, Any] | None:
"""Return the module catalog row for `uuid`, or None if missing.
+31 -4
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import re
from typing import Any
from engine import negatives
from engine.modules import build_resolve_ctx
from engine.syntax import resolve_text
@@ -17,13 +18,32 @@ _COMMA_GAP = re.compile(r",\s*,")
_TRIM_PUNCT = re.compile(r"\s+([,.;:!?])")
def resolve_variables(template: str, ctx: dict[str, Any]) -> str:
def resolve_variables(
template: str,
ctx: dict[str, Any],
*,
reads: list[tuple[str, Any]] | None = None,
) -> str:
"""Resolve `$var` / `@{uuid}` / `{a|b|c}` / `{N$$sep$$...}` against ctx.
Surface defaults to "assembler" (matches wp_nodes/assembler_node.py
usage). For a different surface, call resolve_text directly with a
custom ResolveContext.
`reads`, when given, receives one `(name, index)` per `$var` the
template rendered — what send-to-negative collects negatives from.
"""
return tidy_prompt(resolve_variables_raw(template, ctx, reads=reads))
def resolve_variables_raw(
template: str,
ctx: dict[str, Any],
*,
reads: list[tuple[str, Any]] | None = None,
) -> str:
"""`resolve_variables` without the whitespace cleanup, for callers that
splice several resolved pieces together before tidying once."""
if not template:
return ""
@@ -34,10 +54,17 @@ def resolve_variables(template: str, ctx: dict[str, Any]) -> str:
ctx.setdefault("__wp_warnings__", [])
rctx = build_resolve_ctx(ctx, surface="assembler")
out = resolve_text(template, rctx)
if reads is None:
return resolve_text(template, rctx)
with negatives.collecting(rctx) as col:
out = resolve_text(template, rctx)
reads.extend(col.reads)
return out
# Whitespace cleanup post-pass (preserves the original API's behavior
# for templates that drop missing variables)
def tidy_prompt(out: str) -> str:
"""Whitespace cleanup post-pass (preserves the original API's behavior
for templates that drop missing variables)."""
out = _WS_RUN.sub(" ", out)
out = _COMMA_GAP.sub(",", out)
out = _TRIM_PUNCT.sub(r"\1", out)
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "2.3.0",
"version": "2.20.0",
"private": true,
"description": "Modular procedural prompt generation for ComfyUI",
"author": "DumiFlex",
@@ -9,10 +9,12 @@
"packageManager": "pnpm@9.12.0",
"scripts": {
"dev": "vite build --watch --mode extension",
"build:extension": "vite build --mode extension && pnpm docs:copy",
"build:manager": "vite build --mode manager",
"build:extension": "vite build --mode extension && pnpm docs:copy && pnpm fonts:copy && node scripts/write-asset-manifest.mjs js",
"build:manager": "vite build --mode manager && node scripts/write-asset-manifest.mjs web",
"build": "pnpm build:extension && pnpm build:manager",
"docs:copy": "node scripts/copy-help-docs.mjs",
"fonts:copy": "node scripts/copy-fonts.mjs",
"docs:figures": "node scripts/build-flow-figures.mjs",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint \"src/**/*.{ts,vue}\"",
"size": "node scripts/check-bundle-size.mjs",
@@ -24,7 +26,8 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:py": "pytest",
"prepare": "husky"
"prepare": "husky",
"announce": "node scripts/discord-announce.mjs"
},
"dependencies": {
"pinia": "^2.2.6",
+131 -127
View File
@@ -1,130 +1,134 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 980 280" role="img" aria-label="WP Context emits resolved variables, Prompt Assembler fills them, CLIP Encode produces conditioning">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 202" width="880" height="202" role="img" aria-label="Wildcard, Fixed Values, Combine, Derivation and Constraint modules resolve inside WP Context, which emits resolved dollar-variables that fill the WP Prompt Assembler template, which CLIP Encode turns into conditioning">
<style><![CDATA[
.bg { fill: #14161e; }
.panel { fill: #1a1d2a; }
.border-cyan { stroke: #3a8fb7; }
.border-violet { stroke: #7c6ad6; }
.border-neutral { stroke: #6a6a6a; }
.border-dim { stroke: #353841; }
.ico-cyan { fill: #7ec8e3; }
.ico-violet { fill: #c4b5fd; }
.ico-neutral { fill: #aab; }
.name { fill: #d6d8e0; font: 600 13px 'Inter', system-ui, sans-serif; }
.sub { fill: #6f7384; font: 400 10px ui-monospace, monospace; }
.arrow-lbl { fill: #6f7384; font: 600 10px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
.arrow-line { stroke: #4a4d55; stroke-width: 2; fill: none; }
.arrow-head { fill: #4a4d55; }
.modules-header { fill: #6f7384; font: 600 10px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
.pill-bg { fill: #1a1d2a; }
.pill-stroke-wc { stroke: #b29cf2; }
.pill-stroke-fv { stroke: #2dd4bf; }
.pill-stroke-cb { stroke: #6ad28b; }
.pill-stroke-dv { stroke: #f4a261; }
.pill-stroke-cs { stroke: #ec96b8; }
.pill-stroke-var { stroke: #c4b5fd; }
.pill-text-wc { fill: #b29cf2; }
.pill-text-fv { fill: #2dd4bf; }
.pill-text-cb { fill: #6ad28b; }
.pill-text-dv { fill: #f4a261; }
.pill-text-cs { fill: #ec96b8; }
.pill-text-var { fill: #c4b5fd; }
.pill-text { font: 500 10px 'Inter', system-ui, sans-serif; }
.pill { fill-opacity: 0.12; stroke-width: 1; }
.resolved { fill: #6f7384; font: 600 9.5px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
.down-arrow { stroke: #4a4d55; stroke-width: 1.5; fill: none; }
.frame, .card, .pill, .chip { stroke-width: 1; }
.cap { font: 400 10px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .06em; }
.feeder { font: 400 10.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
.nm { font: 600 12px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
.id { font: 400 9.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
.lbl, .varcap { font: 400 9.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .05em; }
.chipt { font: 400 10.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
.frame { fill: #1e1e1e; stroke: #3a3a3a; }
.card { fill: #232323; stroke: #4a4a4a; }
.card--wp { stroke: #2dd4bf; stroke-opacity: .55; }
.ico--wp { color: #2dd4bf; }
.ico--ext { color: #aaaaaa; }
.ico--wildcard { color: #a78bfa; }
.ico--fixed { color: #22d3ee; }
.ico--combine { color: #34d399; }
.ico--derivation { color: #fbbf24; }
.ico--constraint { color: #f472b6; }
.pill { fill: #232323; stroke: #3a3a3a; }
.feeder { fill: #aaaaaa; }
.nm { fill: #dddddd; }
.id, .lbl, .cap, .varcap { fill: #666666; }
.ln { stroke: #4a4a4a; }
.wash { stop-color: #2dd4bf; }
.flow0 { stop-color: #4a4a4a; }
.head { fill: #4a4a4a; }
.chip { fill: #232323; }
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
.chip--c { stroke: #34d399; stroke-opacity: .4; }
.chipt--a { fill: #c4b5fd; }
.chipt--b { fill: #67e8f9; }
.chipt--c { fill: #86efac; }
@media (prefers-color-scheme: light) {
.frame { fill: #f5f5f5; stroke: #b0b0b0; }
.card { fill: #ffffff; stroke: #999999; }
.card--wp { stroke: #0f766e; stroke-opacity: .55; }
.ico--wp { color: #0f766e; }
.ico--ext { color: #555555; }
.ico--wildcard { color: #6d28d9; }
.ico--fixed { color: #1d4ed8; }
.ico--combine { color: #065f46; }
.ico--derivation { color: #92400e; }
.ico--constraint { color: #9d174d; }
.pill { fill: #ffffff; stroke: #b0b0b0; }
.feeder { fill: #555555; }
.nm { fill: #1a1a1a; }
.id, .lbl, .cap, .varcap { fill: #888888; }
.ln { stroke: #999999; }
.wash { stop-color: #0f766e; }
.flow0 { stop-color: #999999; }
.head { fill: #999999; }
.chip { fill: #ffffff; }
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
.chip--c { stroke: #34d399; stroke-opacity: .4; }
.chipt--a { fill: #6d28d9; }
.chipt--b { fill: #1d4ed8; }
.chipt--c { fill: #065f46; }
}
]]></style>
<rect class="bg" width="980" height="280" rx="8" />
<!-- MODULES INSIDE THE CONTEXT header -->
<text class="modules-header" x="490" y="28" text-anchor="middle">Modules inside the context</text>
<!-- Module kind pills row -->
<g transform="translate(245, 42)">
<!-- Wildcard -->
<rect class="pill pill-stroke-wc" x="0" y="0" width="76" height="22" rx="11" fill="#b29cf2" />
<text class="pill-text pill-text-wc" x="38" y="15" text-anchor="middle">✦ Wildcard</text>
<!-- Fixed Values -->
<rect class="pill pill-stroke-fv" x="84" y="0" width="92" height="22" rx="11" fill="#2dd4bf" />
<text class="pill-text pill-text-fv" x="130" y="15" text-anchor="middle">⌘ Fixed Values</text>
<!-- Combine -->
<rect class="pill pill-stroke-cb" x="184" y="0" width="76" height="22" rx="11" fill="#6ad28b" />
<text class="pill-text pill-text-cb" x="222" y="15" text-anchor="middle">⇄ Combine</text>
<!-- Derivation -->
<rect class="pill pill-stroke-dv" x="268" y="0" width="84" height="22" rx="11" fill="#f4a261" />
<text class="pill-text pill-text-dv" x="310" y="15" text-anchor="middle">⇆ Derivation</text>
<!-- Constraint -->
<rect class="pill pill-stroke-cs" x="360" y="0" width="84" height="22" rx="11" fill="#ec96b8" />
<text class="pill-text pill-text-cs" x="402" y="15" text-anchor="middle">⊥ Constraint</text>
</g>
<!-- Down arrow indicating these go INTO the Context -->
<path class="down-arrow" d="M 490 78 L 490 96 M 484 90 L 490 96 L 496 90" />
<!-- ===== Main flow row at y≈170 ===== -->
<!-- WP Context box -->
<g transform="translate(40, 130)">
<rect class="panel border-cyan" x="0" y="0" width="160" height="100" rx="10" stroke-width="2" />
<text class="ico-cyan" x="80" y="32" text-anchor="middle" font-size="20">⌬</text>
<text class="name" x="80" y="58" text-anchor="middle">WP Context</text>
<text class="sub" x="80" y="78" text-anchor="middle">rolls + resolves</text>
</g>
<!-- EMITS arrow -->
<g transform="translate(210, 180)">
<text class="arrow-lbl" x="50" y="-12" text-anchor="middle">Emits</text>
<line class="arrow-line" x1="0" y1="0" x2="92" y2="0" />
<polygon class="arrow-head" points="92,-5 100,0 92,5" />
</g>
<!-- Resolved vars cluster (no box; chips on transparent) -->
<g transform="translate(320, 160)">
<!-- $subject -->
<rect class="pill pill-stroke-var" x="0" y="0" width="68" height="22" rx="11" fill="#c4b5fd" />
<text class="pill-text pill-text-var" x="34" y="15" text-anchor="middle">$subject</text>
<!-- $style -->
<rect class="pill pill-stroke-var" x="76" y="0" width="52" height="22" rx="11" fill="#c4b5fd" />
<text class="pill-text pill-text-var" x="102" y="15" text-anchor="middle">$style</text>
<!-- $lighting (second row, centered) -->
<rect class="pill pill-stroke-var" x="32" y="30" width="72" height="22" rx="11" fill="#c4b5fd" />
<text class="pill-text pill-text-var" x="68" y="45" text-anchor="middle">$lighting</text>
<!-- RESOLVED $VARS caption -->
<text class="resolved" x="64" y="72" text-anchor="middle">Resolved $vars</text>
</g>
<!-- FILLS arrow -->
<g transform="translate(465, 180)">
<text class="arrow-lbl" x="50" y="-12" text-anchor="middle">Fills</text>
<line class="arrow-line" x1="0" y1="0" x2="92" y2="0" />
<polygon class="arrow-head" points="92,-5 100,0 92,5" />
</g>
<!-- Prompt Assembler box -->
<g transform="translate(575, 130)">
<rect class="panel border-violet" x="0" y="0" width="184" height="100" rx="10" stroke-width="2" />
<text class="ico-violet" x="92" y="32" text-anchor="middle" font-size="20">≡</text>
<text class="name" x="92" y="58" text-anchor="middle">Prompt Assembler</text>
<text class="sub" x="92" y="78" text-anchor="middle">"a $style portrait…"</text>
</g>
<!-- PROMPT arrow -->
<g transform="translate(770, 180)">
<text class="arrow-lbl" x="40" y="-12" text-anchor="middle">Prompt</text>
<line class="arrow-line" x1="0" y1="0" x2="72" y2="0" />
<polygon class="arrow-head" points="72,-5 80,0 72,5" />
</g>
<!-- CLIP Encode box -->
<g transform="translate(860, 130)">
<rect class="panel border-neutral" x="0" y="0" width="88" height="100" rx="10" stroke-width="2" />
<text class="ico-neutral" x="44" y="32" text-anchor="middle" font-size="20">⚡</text>
<text class="name" x="44" y="58" text-anchor="middle">CLIP</text>
<text class="sub" x="44" y="78" text-anchor="middle">conditioning</text>
</g>
<!-- Caption -->
<text class="resolved" x="490" y="260" text-anchor="middle" style="text-transform:none; letter-spacing:0;">
Modules resolve inside WP Context → named $variables → WP Prompt Assembler → CLIP
</text>
<defs>
<!-- Mirrors the SPA frame wash. The CSS is an ellipse 120 percent of the
width by 100 percent of the HEIGHT, anchored top-left — so it spans
the whole panel. Squashing it by the aspect ratio instead pins it to
the corner and leaves the rest looking flat. -->
<radialGradient id="wash" cx="0" cy="0" r="1" gradientTransform="scale(1.2 1)">
<stop class="wash" offset="0" stop-opacity="0.10"/>
<stop class="wash" offset="0.6" stop-opacity="0.02"/>
<stop class="wash" offset="1" stop-opacity="0"/>
</radialGradient>
<linearGradient id="flowv" x1="0" y1="0" x2="0" y2="1">
<stop class="flow0" offset="0" stop-opacity="0"/>
<stop class="flow0" offset="0.25" stop-opacity="1"/>
<stop class="flow0" offset="0.8" stop-opacity="1"/>
<stop class="flow0" offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="flow" x1="0" y1="0" x2="1" y2="0">
<stop class="flow0" offset="0" stop-opacity="0"/>
<stop class="flow0" offset="0.2" stop-opacity="1"/>
<stop class="flow0" offset="0.8" stop-opacity="1"/>
<stop class="flow0" offset="1" stop-opacity="0"/>
</linearGradient>
</defs>
<rect class="frame" x="0.5" y="0.5" width="879" height="201" rx="12"/>
<rect x="0.5" y="0.5" width="879" height="201" rx="12" fill="url(#wash)"/>
<text class="cap" x="440" y="30" text-anchor="middle">MODULES INSIDE THE CONTEXT</text>
<rect class="pill" x="214.69" y="40" width="77.68" height="21" rx="10.5"/>
<g class="ico--wildcard"><g fill="currentColor" transform="translate(224.69 45.5) scale(0.4166666666666667)"><path fill-rule="evenodd" clip-rule="evenodd" d="M18.8923 5.29743L20.1462 5.71539C20.4197 5.80656 20.4197 6.19343 20.1462 6.2846L18.8923 6.70256C18.8027 6.73242 18.7324 6.80272 18.7026 6.8923L18.2846 8.14618C18.1934 8.41969 17.8066 8.41969 17.7154 8.14618L17.2974 6.8923C17.2676 6.80272 17.1973 6.73242 17.1077 6.70256L15.8538 6.2846C15.5803 6.19343 15.5803 5.80656 15.8538 5.71539L17.1077 5.29743C17.1973 5.26757 17.2676 5.19728 17.2974 5.1077L17.7154 3.85381C17.8066 3.5803 18.1934 3.5803 18.2846 3.85381L18.7026 5.1077C18.7324 5.19728 18.8027 5.26757 18.8923 5.29743ZM9.19361 5.04743C9.45192 4.27248 10.5481 4.2725 10.8064 5.04743L12.3429 9.65707L16.9526 11.1936C17.7275 11.4519 17.7275 12.5481 16.9526 12.8064L12.3429 14.3429L10.8064 18.9526C10.5481 19.7275 9.45192 19.7275 9.19361 18.9526L7.65706 14.3429L3.04742 12.8064C2.27247 12.5481 2.27249 11.4519 3.04742 11.1936L7.65706 9.65707L9.19361 5.04743ZM9.99999 7.37171L8.97731 10.4397C8.89271 10.6935 8.69354 10.8927 8.43973 10.9773L5.3717 12L8.43973 13.0227C8.69354 13.1073 8.89271 13.3064 8.97731 13.5603L9.99999 16.6283L11.0227 13.5603C11.1073 13.3064 11.3064 13.1073 11.5603 13.0227L14.6283 12L11.5603 10.9773C11.3064 10.8927 11.1073 10.6935 11.0227 10.4397L9.99999 7.37171ZM20.1462 17.7154L18.8923 17.2974C18.8027 17.2676 18.7324 17.1973 18.7026 17.1077L18.2846 15.8538C18.1934 15.5803 17.8066 15.5803 17.7154 15.8538L17.2974 17.1077C17.2676 17.1973 17.1973 17.2676 17.1077 17.2974L15.8538 17.7154C15.5803 17.8066 15.5803 18.1934 15.8538 18.2846L17.1077 18.7026C17.1973 18.7324 17.2676 18.8027 17.2974 18.8923L17.7154 20.1462C17.8066 20.4197 18.1934 20.4197 18.2846 20.1462L18.7026 18.8923C18.7324 18.8027 18.8027 18.7324 18.8923 18.7026L20.1462 18.2846C20.4197 18.1934 20.4197 17.8066 20.1462 17.7154Z"/></g></g>
<text class="feeder" x="238.69" y="54">Wildcard</text>
<rect class="pill" x="298.37" y="40" width="99.52" height="21" rx="10.5"/>
<g class="ico--fixed"><g fill="currentColor" transform="translate(308.37 45.5) scale(0.4166666666666667)"><path d="M12.32,19.98c-.58,0-1.16-.22-1.6-.66l-6.48-6.47c-.14-.14-.22-.33-.22-.53V4.77c0-.41,.34-.75,.75-.75h7.54c.2,0,.39,.08,.53,.22l6.48,6.48c.87,.88,.87,2.31,0,3.19,0,0,0,0,0,0l-5.41,5.41c-.44,.44-1.02,.66-1.6,.66Zm-6.8-7.97l6.26,6.25c.3,.29,.78,.29,1.07,0l5.41-5.41c.29-.29,.29-.77,0-1.07l-6.26-6.26H5.52v6.48Zm13.27,1.37h0Zm-10.29-3.63c-.69,0-1.25-.56-1.25-1.25s.56-1.25,1.25-1.25,1.25,.56,1.25,1.25-.56,1.25-1.25,1.25Z"/></g></g>
<text class="feeder" x="322.37" y="54">Fixed Values</text>
<rect class="pill" x="403.89" y="40" width="72.22" height="21" rx="10.5"/>
<g class="ico--combine"><g fill="currentColor" transform="translate(413.89 45.5) scale(0.4166666666666667)"><g id="link"><path d="M7.23,18.25A4,4,0,0,1,4.4,17.09a4.23,4.23,0,0,1,.15-5.95L8.31,7.35A4.44,4.44,0,0,1,11.42,6,4,4,0,0,1,14.25,7.2a4.25,4.25,0,0,1-.15,6l-1.26,1.26a.75.75,0,1,1-1.06-1.06L13,12.1a2.73,2.73,0,0,0,.14-3.84,2.77,2.77,0,0,0-3.8.15L5.61,12.19A2.74,2.74,0,0,0,5.46,16a2.5,2.5,0,0,0,2,.71.74.74,0,0,1,.81.67.75.75,0,0,1-.67.82Z"/><path d="M12.58,18A4,4,0,0,1,9.75,16.8a4.25,4.25,0,0,1,.15-6l1.26-1.26a.75.75,0,1,1,1.06,1.06L11,11.9a2.73,2.73,0,0,0-.14,3.84,2.77,2.77,0,0,0,3.8-.15l3.77-3.78A2.74,2.74,0,0,0,18.54,8a2.5,2.5,0,0,0-2-.71.74.74,0,0,1-.81-.67.75.75,0,0,1,.67-.82A4,4,0,0,1,19.6,6.91a4.23,4.23,0,0,1-.15,5.95l-3.76,3.79A4.44,4.44,0,0,1,12.58,18Z"/></g></g></g>
<text class="feeder" x="427.89" y="54">Combine</text>
<rect class="pill" x="482.11" y="40" width="88.6" height="21" rx="10.5"/>
<g class="ico--derivation"><g fill="currentColor" transform="translate(492.11 45.5) scale(0.4166666666666667)"><path d="M19.75,16c0,.41-.34,.75-.75,.75H6.81l1.22,1.22c.29,.29,.29,.77,0,1.06-.15,.15-.34,.22-.53,.22s-.38-.07-.53-.22l-2.5-2.5c-.07-.07-.12-.15-.16-.24-.08-.18-.08-.39,0-.57,.04-.09,.09-.17,.16-.24l2.5-2.5c.29-.29,.77-.29,1.06,0s.29,.77,0,1.06l-1.22,1.22h12.19c.41,0,.75,.34,.75,.75ZM5,8.75h12.19l-1.22,1.22c-.29,.29-.29,.77,0,1.06,.15,.15,.34,.22,.53,.22s.38-.07,.53-.22l2.5-2.5c.07-.07,.12-.15,.16-.24,.08-.18,.08-.39,0-.57-.04-.09-.09-.17-.16-.24l-2.5-2.5c-.29-.29-.77-.29-1.06,0s-.29,.77,0,1.06l1.22,1.22H5c-.41,0-.75,.34-.75,.75s.34,.75,.75,.75Z"/></g></g>
<text class="feeder" x="506.11" y="54">Derivation</text>
<rect class="pill" x="576.71" y="40" width="88.6" height="21" rx="10.5"/>
<g class="ico--constraint"><g fill="currentColor" transform="translate(586.71 45.5) scale(0.4166666666666667)"><g id="filter"><path d="M14,20.5H10a.76.76,0,0,1-.75-.75V12L3.9,4.69a.73.73,0,0,1-.07-.78A.76.76,0,0,1,4.5,3.5h15a.76.76,0,0,1,.67.41.73.73,0,0,1-.07.78L14.75,12v7.75A.76.76,0,0,1,14,20.5ZM10.75,19h2.5V11.75a.71.71,0,0,1,.15-.44L18,5H6l4.62,6.31a.71.71,0,0,1,.15.44Z"/></g></g></g>
<text class="feeder" x="600.71" y="54">Constraint</text>
<g class="ico--ext"><g fill="currentColor" transform="translate(434 72) scale(0.5)"><g id="angle-down"><path d="M12,14.5a.74.74,0,0,1-.53-.22L8,10.78A.75.75,0,0,1,9,9.72l3,3,3-3A.75.75,0,0,1,16,10.78l-3.5,3.5A.74.74,0,0,1,12,14.5Z"/></g></g></g>
<rect class="card card--wp" x="50" y="104" width="124" height="66" rx="10"/>
<g class="ico ico--wp"><g fill="currentColor" transform="translate(104 115) scale(0.6666666666666666)"><g id="sitemap"><path d="M13.5,9.75h-3A1.76,1.76,0,0,1,8.75,8V5A1.76,1.76,0,0,1,10.5,3.25h3A1.76,1.76,0,0,1,15.25,5V8A1.76,1.76,0,0,1,13.5,9.75Zm-3-5a.25.25,0,0,0-.25.25V8a.25.25,0,0,0,.25.25h3A.25.25,0,0,0,13.75,8V5a.25.25,0,0,0-.25-.25Z"/><path d="M6,19.75H4A1.76,1.76,0,0,1,2.25,18V16A1.76,1.76,0,0,1,4,14.25H6A1.76,1.76,0,0,1,7.75,16v2A1.76,1.76,0,0,1,6,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25H6A.25.25,0,0,0,6.25,18V16A.25.25,0,0,0,6,15.75Z"/><path d="M13,19.75H11A1.76,1.76,0,0,1,9.25,18V16A1.76,1.76,0,0,1,11,14.25h2A1.76,1.76,0,0,1,14.75,16v2A1.76,1.76,0,0,1,13,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M20,19.75H18A1.76,1.76,0,0,1,16.25,18V16A1.76,1.76,0,0,1,18,14.25h2A1.76,1.76,0,0,1,21.75,16v2A1.76,1.76,0,0,1,20,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M19,15.75a.76.76,0,0,1-.75-.75V13a.25.25,0,0,0-.25-.25H6a.25.25,0,0,0-.25.25v2a.75.75,0,0,1-1.5,0V13A1.76,1.76,0,0,1,6,11.25H18A1.76,1.76,0,0,1,19.75,13v2A.76.76,0,0,1,19,15.75Z"/><path d="M12,15.75a.76.76,0,0,1-.75-.75V9a.75.75,0,0,1,1.5,0v6A.76.76,0,0,1,12,15.75Z"/></g></g></g>
<text class="nm" x="112" y="147" text-anchor="middle">WP Context</text>
<text class="id" x="112" y="161" text-anchor="middle">rolls + resolves</text>
<text class="lbl" x="226" y="128" text-anchor="middle">EMITS</text>
<rect fill="url(#flow)" x="182" y="136.2" width="82" height="1.6"/>
<path class="head" d="M264 132.5 L270 137 L264 141.5 Z"/>
<rect class="chip chip--a" x="287.4" y="102" width="64.4" height="19" rx="9.5"/>
<text class="chipt chipt--a" x="319.59999999999997" y="115" text-anchor="middle">$subject</text>
<rect class="chip chip--b" x="356.79999999999995" y="102" width="51.8" height="19" rx="9.5"/>
<text class="chipt chipt--b" x="382.69999999999993" y="115" text-anchor="middle">$style</text>
<rect class="chip chip--c" x="312.65" y="126" width="70.69999999999999" height="19" rx="9.5"/>
<text class="chipt chipt--c" x="348" y="139" text-anchor="middle">$lighting</text>
<text class="varcap" x="348" y="164" text-anchor="middle">RESOLVED $VARS</text>
<text class="lbl" x="457" y="128" text-anchor="middle">FILLS</text>
<rect fill="url(#flow)" x="426" y="136.2" width="56" height="1.6"/>
<path class="head" d="M482 132.5 L488 137 L482 141.5 Z"/>
<rect class="card card--wp" x="496" y="104" width="140" height="66" rx="10"/>
<g class="ico ico--wp"><g fill="currentColor" transform="translate(558 115) scale(0.6666666666666666)"><g id="align-left"><path d="M13.93,11h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,7h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,15h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M13.93,19h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/></g></g></g>
<text class="nm" x="566" y="147" text-anchor="middle">Prompt Assembler</text>
<text class="id" x="566" y="161" text-anchor="middle">"A $style portrait…"</text>
<text class="lbl" x="673" y="128" text-anchor="middle">PROMPT</text>
<rect fill="url(#flow)" x="644" y="136.2" width="52" height="1.6"/>
<path class="head" d="M696 132.5 L702 137 L696 141.5 Z"/>
<rect class="card card--ext" x="710" y="104" width="124" height="66" rx="10"/>
<g class="ico ico--ext"><g fill="currentColor" transform="translate(764 115) scale(0.6666666666666666)"><g id="bolt"><path d="M11.09,21.5a.67.67,0,0,1-.24,0,.83.83,0,0,1-.59-.81v-.11l.9-6.35H6.82a.8.8,0,0,1-.71-.43.85.85,0,0,1,0-.86l2-3.49,4.1-6.52a.79.79,0,0,1,.92-.35.83.83,0,0,1,.59.81v.11l-.9,6.35h4.35a.8.8,0,0,1,.71.43.85.85,0,0,1,0,.86l-2,3.49-4.1,6.52A.79.79,0,0,1,11.09,21.5Zm-.53-1.21h0Zm-2.63-7.6h4a.84.84,0,0,1,.83.85v.11l-.59,4.14,2.5-4,1.44-2.48h-4a.84.84,0,0,1-.83-.85v-.11l.59-4.14-2.5,4Zm-.57,1Zm9.28-3.34ZM13.44,3.7h0Z"/></g></g></g>
<text class="nm" x="772" y="147" text-anchor="middle">CLIP Encode</text>
<text class="id" x="772" y="161" text-anchor="middle">conditioning</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+129 -99
View File
@@ -1,102 +1,132 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 980 240" role="img" aria-label="WP Context Loop emits N prompts and feeds WP Seed List N seeds; both fan out into KSampler in lockstep">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 286" width="880" height="286" role="img" aria-label="WP Context Loop drives WP Context and the Prompt Assembler to produce N prompts, CLIP Encode turns them into conditioning for KSampler, and WP Seed List supplies the matching seed for each iteration directly to KSampler">
<style><![CDATA[
.bg { fill: #14161e; }
.panel { fill: #1a1d2a; }
.border-loop { stroke: #7c6ad6; }
.border-ctx { stroke: #3a8fb7; }
.border-asm { stroke: #7c6ad6; }
.border-seed { stroke: #3a8fb7; }
.border-k { stroke: #6a6a6a; }
.ico-loop { fill: #c4b5fd; }
.ico-ctx { fill: #7ec8e3; }
.ico-asm { fill: #c4b5fd; }
.ico-seed { fill: #7ec8e3; }
.ico-k { fill: #aab; }
.name { fill: #d6d8e0; font: 600 13px 'Inter', system-ui, sans-serif; }
.sub { fill: #6f7384; font: 400 10px ui-monospace, monospace; }
.arrow-lbl { fill: #6f7384; font: 600 10px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
.arrow-line { stroke: #4a4d55; stroke-width: 2; fill: none; }
.arrow-line-dim { stroke: #353841; stroke-width: 2; fill: none; stroke-dasharray: 5 4; }
.arrow-head { fill: #4a4d55; }
.caption { fill: #6f7384; font: 500 11px 'Inter', system-ui, sans-serif; }
.frame, .card, .pill, .chip { stroke-width: 1; }
.cap { font: 400 10px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .06em; }
.feeder { font: 400 10.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
.nm { font: 600 12px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
.id { font: 400 9.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
.lbl, .varcap { font: 400 9.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .05em; }
.chipt { font: 400 10.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
.frame { fill: #1e1e1e; stroke: #3a3a3a; }
.card { fill: #232323; stroke: #4a4a4a; }
.card--wp { stroke: #2dd4bf; stroke-opacity: .55; }
.ico--wp { color: #2dd4bf; }
.ico--ext { color: #aaaaaa; }
.ico--wildcard { color: #a78bfa; }
.ico--fixed { color: #22d3ee; }
.ico--combine { color: #34d399; }
.ico--derivation { color: #fbbf24; }
.ico--constraint { color: #f472b6; }
.pill { fill: #232323; stroke: #3a3a3a; }
.feeder { fill: #aaaaaa; }
.nm { fill: #dddddd; }
.id, .lbl, .cap, .varcap { fill: #666666; }
.ln { stroke: #4a4a4a; }
.wash { stop-color: #2dd4bf; }
.flow0 { stop-color: #4a4a4a; }
.head { fill: #4a4a4a; }
.chip { fill: #232323; }
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
.chip--c { stroke: #34d399; stroke-opacity: .4; }
.chipt--a { fill: #c4b5fd; }
.chipt--b { fill: #67e8f9; }
.chipt--c { fill: #86efac; }
@media (prefers-color-scheme: light) {
.frame { fill: #f5f5f5; stroke: #b0b0b0; }
.card { fill: #ffffff; stroke: #999999; }
.card--wp { stroke: #0f766e; stroke-opacity: .55; }
.ico--wp { color: #0f766e; }
.ico--ext { color: #555555; }
.ico--wildcard { color: #6d28d9; }
.ico--fixed { color: #1d4ed8; }
.ico--combine { color: #065f46; }
.ico--derivation { color: #92400e; }
.ico--constraint { color: #9d174d; }
.pill { fill: #ffffff; stroke: #b0b0b0; }
.feeder { fill: #555555; }
.nm { fill: #1a1a1a; }
.id, .lbl, .cap, .varcap { fill: #888888; }
.ln { stroke: #999999; }
.wash { stop-color: #0f766e; }
.flow0 { stop-color: #999999; }
.head { fill: #999999; }
.chip { fill: #ffffff; }
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
.chip--c { stroke: #34d399; stroke-opacity: .4; }
.chipt--a { fill: #6d28d9; }
.chipt--b { fill: #1d4ed8; }
.chipt--c { fill: #065f46; }
}
]]></style>
<rect class="bg" width="980" height="240" rx="8" />
<!-- WP Context Loop -->
<g transform="translate(30, 70)">
<rect class="panel border-loop" x="0" y="0" width="160" height="100" rx="10" stroke-width="2" />
<text class="ico-loop" x="80" y="32" text-anchor="middle" font-size="20">↺</text>
<text class="name" x="80" y="58" text-anchor="middle">WP Context Loop</text>
<text class="sub" x="80" y="78" text-anchor="middle">N prompts</text>
</g>
<!-- Loop splits into TWO lanes -->
<!-- Lane A: loop_config → Seed List → seed[] -->
<g transform="translate(200, 60)">
<text class="arrow-lbl" x="50" y="-6" text-anchor="middle">loop_config</text>
<line class="arrow-line" x1="0" y1="6" x2="92" y2="6" />
<polygon class="arrow-head" points="92,1 100,6 92,11" />
</g>
<g transform="translate(310, 30)">
<rect class="panel border-seed" x="0" y="0" width="160" height="80" rx="10" stroke-width="2" />
<text class="ico-seed" x="80" y="28" text-anchor="middle" font-size="18">#</text>
<text class="name" x="80" y="50" text-anchor="middle">WP Seed List</text>
<text class="sub" x="80" y="68" text-anchor="middle">N seeds</text>
</g>
<!-- Lane B: context → WP Context -->
<g transform="translate(200, 160)">
<text class="arrow-lbl" x="50" y="-6" text-anchor="middle">context</text>
<line class="arrow-line" x1="0" y1="6" x2="92" y2="6" />
<polygon class="arrow-head" points="92,1 100,6 92,11" />
</g>
<g transform="translate(310, 130)">
<rect class="panel border-ctx" x="0" y="0" width="160" height="80" rx="10" stroke-width="2" />
<text class="ico-ctx" x="80" y="28" text-anchor="middle" font-size="18">⌬</text>
<text class="name" x="80" y="50" text-anchor="middle">WP Context</text>
<text class="sub" x="80" y="68" text-anchor="middle">module stack</text>
</g>
<!-- WP Context → Prompt Assembler -->
<g transform="translate(480, 160)">
<line class="arrow-line" x1="0" y1="6" x2="62" y2="6" />
<polygon class="arrow-head" points="62,1 70,6 62,11" />
</g>
<!-- Prompt Assembler -->
<g transform="translate(560, 130)">
<rect class="panel border-asm" x="0" y="0" width="160" height="80" rx="10" stroke-width="2" />
<text class="ico-asm" x="80" y="28" text-anchor="middle" font-size="18">≡</text>
<text class="name" x="80" y="50" text-anchor="middle">Prompt Assembler</text>
<text class="sub" x="80" y="68" text-anchor="middle">N prompts</text>
</g>
<!-- Seed List → KSampler (top lane) -->
<g transform="translate(480, 60)">
<text class="arrow-lbl" x="180" y="-6" text-anchor="middle">seed[]</text>
<path class="arrow-line" d="M 0 6 L 360 6 L 360 64" />
<polygon class="arrow-head" points="355,59 360,67 365,59" />
</g>
<!-- Assembler → KSampler (bottom lane joining top arrow at sampler) -->
<g transform="translate(730, 160)">
<text class="arrow-lbl" x="40" y="-6" text-anchor="middle">prompt[]</text>
<line class="arrow-line" x1="0" y1="6" x2="62" y2="6" />
<polygon class="arrow-head" points="62,1 70,6 62,11" />
</g>
<!-- KSampler -->
<g transform="translate(810, 130)">
<rect class="panel border-k" x="0" y="0" width="140" height="80" rx="10" stroke-width="2" />
<text class="ico-k" x="70" y="28" text-anchor="middle" font-size="18">⚡</text>
<text class="name" x="70" y="50" text-anchor="middle">KSampler</text>
<text class="sub" x="70" y="68" text-anchor="middle">N unique images</text>
</g>
<!-- Caption -->
<text class="caption" x="490" y="225" text-anchor="middle">
Loop emits N prompts · Seed List emits N seeds · both lists fan out into the sampler in lockstep — iteration N's prompt pairs with seed N
</text>
<defs>
<!-- Mirrors the SPA frame wash. The CSS is an ellipse 120 percent of the
width by 100 percent of the HEIGHT, anchored top-left — so it spans
the whole panel. Squashing it by the aspect ratio instead pins it to
the corner and leaves the rest looking flat. -->
<radialGradient id="wash" cx="0" cy="0" r="1" gradientTransform="scale(1.2 1)">
<stop class="wash" offset="0" stop-opacity="0.10"/>
<stop class="wash" offset="0.6" stop-opacity="0.02"/>
<stop class="wash" offset="1" stop-opacity="0"/>
</radialGradient>
<linearGradient id="flowv" x1="0" y1="0" x2="0" y2="1">
<stop class="flow0" offset="0" stop-opacity="0"/>
<stop class="flow0" offset="0.25" stop-opacity="1"/>
<stop class="flow0" offset="0.8" stop-opacity="1"/>
<stop class="flow0" offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="flow" x1="0" y1="0" x2="1" y2="0">
<stop class="flow0" offset="0" stop-opacity="0"/>
<stop class="flow0" offset="0.2" stop-opacity="1"/>
<stop class="flow0" offset="0.8" stop-opacity="1"/>
<stop class="flow0" offset="1" stop-opacity="0"/>
</linearGradient>
</defs>
<rect class="frame" x="0.5" y="0.5" width="879" height="285" rx="12"/>
<rect x="0.5" y="0.5" width="879" height="285" rx="12" fill="url(#wash)"/>
<text class="cap" x="440" y="30" text-anchor="middle">BOTH LISTS FAN OUT IN LOCKSTEP — ITERATION N MEETS SEED N</text>
<rect class="card card--wp" x="48.199999999999996" y="54" width="127.60000000000001" height="66" rx="10"/>
<g class="ico ico--wp"><g fill="currentColor" transform="translate(104 65) scale(0.6666666666666666)"><g id="refresh"><path d="M12,20.75a7.25,7.25,0,0,1,0-14.5h2.5a.75.75,0,0,1,0,1.5H12a5.75,5.75,0,1,0,5.75,5.75.75.75,0,0,1,1.5,0A7.26,7.26,0,0,1,12,20.75Z"/><path d="M12,10.75a.74.74,0,0,1-.53-.22.75.75,0,0,1,0-1.06L13.94,7,11.47,4.53a.75.75,0,1,1,1.06-1.06l3,3a.75.75,0,0,1,0,1.06l-3,3A.74.74,0,0,1,12,10.75Z"/></g></g></g>
<text class="nm" x="112" y="97" text-anchor="middle">WP Context Loop</text>
<text class="id" x="112" y="111" text-anchor="middle">N iterations</text>
<rect class="card card--wp" x="50" y="158" width="124" height="66" rx="10"/>
<g class="ico ico--wp"><g fill="currentColor" transform="translate(104 169) scale(0.6666666666666666)"><g id="list"><path d="M19,12.75H8a.75.75,0,0,1,0-1.5H19a.75.75,0,0,1,0,1.5Z"/><path d="M19,8.25H8a.75.75,0,0,1,0-1.5H19a.75.75,0,0,1,0,1.5Z"/><path d="M19,17.25H8a.75.75,0,0,1,0-1.5H19a.75.75,0,0,1,0,1.5Z"/><path d="M5,8.5a1,1,0,0,1-.38-.07,1.46,1.46,0,0,1-.33-.22A1,1,0,0,1,4,7.5a1.05,1.05,0,0,1,.29-.71.93.93,0,0,1,.33-.21,1,1,0,0,1,.76,0,1,1,0,0,1,.33.21A1.05,1.05,0,0,1,6,7.5a1,1,0,0,1-.29.71,1.46,1.46,0,0,1-.33.22A1,1,0,0,1,5,8.5Z"/><path d="M5,13a1,1,0,0,1-.38-.08,1.15,1.15,0,0,1-.33-.21A1,1,0,0,1,4,12a1.05,1.05,0,0,1,.29-.71,1.15,1.15,0,0,1,.33-.21A1,1,0,0,1,5.2,11l.18.06.18.09a1.58,1.58,0,0,1,.15.12A1.05,1.05,0,0,1,6,12a1,1,0,0,1-1,1Z"/><path d="M5,17.5a1,1,0,0,1-.38-.07,1.46,1.46,0,0,1-.33-.22,1.15,1.15,0,0,1-.21-.33.94.94,0,0,1,0-.76,1.15,1.15,0,0,1,.21-.33,1,1,0,0,1,1.09-.21,1,1,0,0,1,.33.21,1.15,1.15,0,0,1,.21.33.94.94,0,0,1,0,.76,1.15,1.15,0,0,1-.21.33A1,1,0,0,1,5,17.5Z"/></g></g></g>
<text class="nm" x="112" y="201" text-anchor="middle">WP Seed List</text>
<text class="id" x="112" y="215" text-anchor="middle">N seeds</text>
<rect class="card card--wp" x="278" y="54" width="124" height="66" rx="10"/>
<g class="ico ico--wp"><g fill="currentColor" transform="translate(332 65) scale(0.6666666666666666)"><g id="sitemap"><path d="M13.5,9.75h-3A1.76,1.76,0,0,1,8.75,8V5A1.76,1.76,0,0,1,10.5,3.25h3A1.76,1.76,0,0,1,15.25,5V8A1.76,1.76,0,0,1,13.5,9.75Zm-3-5a.25.25,0,0,0-.25.25V8a.25.25,0,0,0,.25.25h3A.25.25,0,0,0,13.75,8V5a.25.25,0,0,0-.25-.25Z"/><path d="M6,19.75H4A1.76,1.76,0,0,1,2.25,18V16A1.76,1.76,0,0,1,4,14.25H6A1.76,1.76,0,0,1,7.75,16v2A1.76,1.76,0,0,1,6,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25H6A.25.25,0,0,0,6.25,18V16A.25.25,0,0,0,6,15.75Z"/><path d="M13,19.75H11A1.76,1.76,0,0,1,9.25,18V16A1.76,1.76,0,0,1,11,14.25h2A1.76,1.76,0,0,1,14.75,16v2A1.76,1.76,0,0,1,13,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M20,19.75H18A1.76,1.76,0,0,1,16.25,18V16A1.76,1.76,0,0,1,18,14.25h2A1.76,1.76,0,0,1,21.75,16v2A1.76,1.76,0,0,1,20,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M19,15.75a.76.76,0,0,1-.75-.75V13a.25.25,0,0,0-.25-.25H6a.25.25,0,0,0-.25.25v2a.75.75,0,0,1-1.5,0V13A1.76,1.76,0,0,1,6,11.25H18A1.76,1.76,0,0,1,19.75,13v2A.76.76,0,0,1,19,15.75Z"/><path d="M12,15.75a.76.76,0,0,1-.75-.75V9a.75.75,0,0,1,1.5,0v6A.76.76,0,0,1,12,15.75Z"/></g></g></g>
<text class="nm" x="340" y="97" text-anchor="middle">WP Context</text>
<text class="id" x="340" y="111" text-anchor="middle">module stack</text>
<rect class="card card--wp" x="499.08" y="54" width="133.84" height="66" rx="10"/>
<g class="ico ico--wp"><g fill="currentColor" transform="translate(558 65) scale(0.6666666666666666)"><g id="align-left"><path d="M13.93,11h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,7h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,15h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M13.93,19h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/></g></g></g>
<text class="nm" x="566" y="97" text-anchor="middle">Prompt Assembler</text>
<text class="id" x="566" y="111" text-anchor="middle">N prompts</text>
<rect class="card card--ext" x="504" y="158" width="124" height="66" rx="10"/>
<g class="ico ico--ext"><g fill="currentColor" transform="translate(558 169) scale(0.6666666666666666)"><g id="bolt"><path d="M11.09,21.5a.67.67,0,0,1-.24,0,.83.83,0,0,1-.59-.81v-.11l.9-6.35H6.82a.8.8,0,0,1-.71-.43.85.85,0,0,1,0-.86l2-3.49,4.1-6.52a.79.79,0,0,1,.92-.35.83.83,0,0,1,.59.81v.11l-.9,6.35h4.35a.8.8,0,0,1,.71.43.85.85,0,0,1,0,.86l-2,3.49-4.1,6.52A.79.79,0,0,1,11.09,21.5Zm-.53-1.21h0Zm-2.63-7.6h4a.84.84,0,0,1,.83.85v.11l-.59,4.14,2.5-4,1.44-2.48h-4a.84.84,0,0,1-.83-.85v-.11l.59-4.14-2.5,4Zm-.57,1Zm9.28-3.34ZM13.44,3.7h0Z"/></g></g></g>
<text class="nm" x="566" y="201" text-anchor="middle">CLIP Encode</text>
<text class="id" x="566" y="215" text-anchor="middle">N conditionings</text>
<rect class="card card--ext" x="710" y="158" width="124" height="66" rx="10"/>
<g class="ico ico--ext"><g fill="currentColor" transform="translate(764 169) scale(0.6666666666666666)"><g id="image"><path d="M18,4.25H6A2.75,2.75,0,0,0,3.25,7V17A2.75,2.75,0,0,0,6,19.75H18A2.75,2.75,0,0,0,20.75,17V7A2.75,2.75,0,0,0,18,4.25ZM6,5.75H18A1.25,1.25,0,0,1,19.25,7v8.19l-2.72-2.72a.7.7,0,0,0-.56-.22.79.79,0,0,0-.55.27l-1.29,1.55-4.6-4.6A.7.7,0,0,0,9,9.25a.79.79,0,0,0-.55.27L4.75,13.93V7A1.25,1.25,0,0,1,6,5.75ZM4.75,17v-.73l4.3-5.16,4.12,4.12-2.52,3H6A1.25,1.25,0,0,1,4.75,17ZM18,18.25H12.6l3.45-4.14,3.15,3.15A1.23,1.23,0,0,1,18,18.25Z"/></g></g></g>
<text class="nm" x="772" y="201" text-anchor="middle">KSampler</text>
<text class="id" x="772" y="215" text-anchor="middle">N images</text>
<text class="lbl" x="226.9" y="78" text-anchor="middle">CONTEXT</text>
<rect fill="url(#flow)" x="183.8" y="86.2" width="80.19999999999999" height="1.6"/>
<path class="head" d="M264 82.5 L270 87 L264 91.5 Z"/>
<text class="lbl" x="122" y="143">LOOP_CONFIG</text>
<rect fill="url(#flowv)" x="111.2" y="120" width="1.6" height="32"/>
<path class="head" d="M108 152 L112 158 L116 152 Z"/>
<text class="lbl" x="450.53999999999996" y="78" text-anchor="middle">$VARS</text>
<rect fill="url(#flow)" x="410" y="86.2" width="75.07999999999998" height="1.6"/>
<path class="head" d="M485.08 82.5 L491.08 87 L485.08 91.5 Z"/>
<text class="lbl" x="576" y="141">PROMPT</text>
<rect fill="url(#flowv)" x="565.2" y="120" width="1.6" height="32"/>
<path class="head" d="M562 152 L566 158 L570 152 Z"/>
<text class="lbl" x="669" y="182" text-anchor="middle">COND</text>
<rect fill="url(#flow)" x="636" y="190.2" width="60" height="1.6"/>
<path class="head" d="M696 186.5 L702 191 L696 195.5 Z"/>
<path class="ln" fill="none" d="M112 224 V 252 H 772 V 230"/>
<path class="head" d="M768 230 L772 224 L776 230 Z"/>
<text class="lbl" x="420" y="244" text-anchor="middle">SEED[]</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

+3 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-wildcard-pipeline"
version = "2.3.0"
version = "2.20.0"
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
readme = "README.md"
requires-python = ">=3.10"
@@ -22,6 +22,8 @@ dev = [
"pytest>=8.0",
"pytest-aiohttp>=1.0",
"pytest-asyncio>=0.23",
# ComfyUI ships PyYAML; the wildcard file importer reads .yaml packs with it.
"pyyaml>=6.0",
"ruff>=0.5",
]
+342
View File
@@ -0,0 +1,342 @@
/**
* Generate the README flow figures.
*
* The SPA draws these as Vue + CSS with PrimeIcons webfont glyphs
* (`src/manager/components/docs/PipelineDiagram.vue`, framed by
* `DocFigure.vue`). A README can run neither: GitHub serves the file through
* `<img>`, so there is no component runtime and no icon font.
*
* So this reproduces that figure in SVG, deriving every value from the real
* component rather than approximating it by eye:
*
* frame --wp-bg-1 / --wp-border, radius --wp-radius-lg (12)
* card --wp-bg-2, --wp-border-strong, radius 10, min-width 120,
* column layout: icon 16 / name 12 semibold / id 9.5 mono dim
* WP card border tinted with --wp-node, icon in --wp-node
* feeders pills, --wp-bg-2 + --wp-border, 10.5px, icon in the kind colour
* labels 9.5px uppercase, letter-spacing .04em, --wp-text-dim
* var chips mono 10.5px, pill, per-chip accent border
*
* Icons are the REAL PrimeIcons paths from `primeicons/raw-svg` — the same
* source the webfont is built from — so the README shows the same glyphs the
* app does.
*
* Both themes ship in one file: GitHub honours `prefers-color-scheme` inside
* an SVG served to `<img>`, so the light block below mirrors the SPA's light
* theme rather than washing the dark one out.
*
* Run: node scripts/build-flow-figures.mjs
*/
import fs from "node:fs";
import path from "node:path";
const ICON_DIR = "node_modules/primeicons/raw-svg";
const OUT_DIR = "public/images/docs";
/* ── tokens, lifted from src/components/shared/theme.css + manager tokens ── */
const DARK = {
frameBg: "#1e1e1e", frameBd: "#3a3a3a",
cardBg: "#232323", cardBd: "#4a4a4a",
node: "#2dd4bf",
text: "#dddddd", muted: "#aaaaaa", dim: "#666666",
wildcard: "#a78bfa", fixed: "#22d3ee", combine: "#34d399",
derivation: "#fbbf24", constraint: "#f472b6",
chipA: "#c4b5fd", chipAb: "#8b5cf6",
chipB: "#67e8f9", chipBb: "#22d3ee",
chipC: "#86efac", chipCb: "#34d399",
};
const LIGHT = {
frameBg: "#f5f5f5", frameBd: "#b0b0b0",
cardBg: "#ffffff", cardBd: "#999999",
node: "#0f766e",
text: "#1a1a1a", muted: "#555555", dim: "#888888",
wildcard: "#6d28d9", fixed: "#1d4ed8", combine: "#065f46",
derivation: "#92400e", constraint: "#9d174d",
chipA: "#6d28d9", chipAb: "#8b5cf6",
chipB: "#1d4ed8", chipBb: "#22d3ee",
chipC: "#065f46", chipCb: "#34d399",
};
const SANS = "'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif";
const MONO = "'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace";
/** Rough advance width. Good enough to size pills; every label is centred, so
* a few px of slop never misaligns text against its box. */
const textW = (s, px, mono = false) => s.length * px * (mono ? 0.6 : 0.52);
/** Inner markup of a PrimeIcon, scaled to `size` and centred on (cx, cy).
*
* Colour comes from the wrapping `<g fill="currentColor">`, NOT from rewriting
* each path. Most PrimeIcons declare no `fill` at all (only `sparkles` does),
* so a rewrite-what-exists approach leaves them at SVG's default black — which
* is exactly how every node icon came out black while the feeder pills, whose
* icons happened to carry fills, coloured correctly.
*
* `fill="none"` is preserved: on the icons that use it, it marks the part that
* is deliberately hollow, and filling it would blob the glyph.
*/
function icon(name, cx, cy, size) {
const raw = fs.readFileSync(path.join(ICON_DIR, `${name}.svg`), "utf8");
const inner = raw
.replace(/^[\s\S]*?<svg[^>]*>/, "")
.replace(/<\/svg>\s*$/, "")
.replace(/\s(?:width|height)="[^"]*"/g, "")
// Drop concrete fills so the group's currentColor wins; keep `none`.
.replace(/\sfill="(?!none)[^"]*"/g, "")
.replace(/\sstroke="(?!none)[^"]*"/g, ' stroke="currentColor"')
.trim();
const s = size / 24;
return `<g fill="currentColor" transform="translate(${cx - size / 2} ${cy - size / 2}) scale(${s})">${inner}</g>`;
}
/** A node card. Vertical, centred: icon, name, mono subtitle — the SPA's
* `.wp-doc-diagram__node-box`. `wp` cards take the teal node accent. */
function card(cx, top, label, sub, iconName, kind = "wp") {
const w = Math.max(124, textW(label, 12) + 34, textW(sub, 9.5, true) + 26);
const h = 66;
const x = cx - w / 2;
return {
w, h,
svg: [
`<rect class="card card--${kind}" x="${x}" y="${top}" width="${w}" height="${h}" rx="10"/>`,
`<g class="ico ico--${kind}">${icon(iconName, cx, top + 19, 16)}</g>`,
`<text class="nm" x="${cx}" y="${top + 43}" text-anchor="middle">${label}</text>`,
`<text class="id" x="${cx}" y="${top + 57}" text-anchor="middle">${sub}</text>`,
].join("\n "),
};
}
/** Labelled connector, matching `.wp-doc-diagram__arrow`: uppercase label
* above a hairline that ends in a small solid head. */
function connector(x1, x2, y, label) {
const mid = (x1 + x2) / 2;
return [
label ? `<text class="lbl" x="${mid}" y="${y - 9}" text-anchor="middle">${label}</text>` : "",
// Gradient fill, not a stroke: the SPA's `.wp-doc-diagram__arrow-ln` fades
// in and out at both ends so a connector reads as a flow rather than a
// drawn border. It has to be a RECT — a horizontal <line> has a zero-height
// bounding box, so an objectBoundingBox gradient on it collapses and the
// line disappears entirely, leaving a floating arrowhead.
`<rect fill="url(#flow)" x="${x1}" y="${y - 0.8}" width="${x2 - 6 - x1}" height="1.6"/>`,
`<path class="head" d="M${x2 - 6} ${y - 4.5} L${x2} ${y} L${x2 - 6} ${y + 4.5} Z"/>`,
].join("\n ");
}
/** Pill row of the five module kinds — `.wp-doc-diagram__feeder`. */
function feeders(cx, y) {
const items = [
["sparkles", "Wildcard", "wildcard"],
["tag", "Fixed Values", "fixed"],
["link", "Combine", "combine"],
["arrow-right-arrow-left", "Derivation", "derivation"],
["filter", "Constraint", "constraint"],
];
const gap = 6;
const pills = items.map(([ico, label, kind]) => {
const w = 9 + 10 + 6 + textW(label, 10.5) + 9;
return { ico, label, kind, w };
});
const total = pills.reduce((a, p) => a + p.w, 0) + gap * (pills.length - 1);
let x = cx - total / 2;
const out = pills.map((p) => {
const s = [
`<rect class="pill" x="${x}" y="${y}" width="${p.w}" height="21" rx="10.5"/>`,
`<g class="ico--${p.kind}">${icon(p.ico, x + 15, y + 10.5, 10)}</g>`,
`<text class="feeder" x="${x + 24}" y="${y + 14}">${p.label}</text>`,
].join("\n ");
x += p.w + gap;
return s;
});
return out.join("\n ");
}
/** The `$vars` stage: chips over a small uppercase caption. */
function varBag(cx, top) {
const chips = [["$subject", "a"], ["$style", "b"], ["$lighting", "c"]];
const gap = 5;
const sized = chips.map(([t, v]) => ({ t, v, w: textW(t, 10.5, true) + 14 }));
// Two rows, mirroring the SPA's 150px-capped wrapping bag.
const row1 = sized.slice(0, 2);
const row2 = sized.slice(2);
const line = (items, y) => {
const total = items.reduce((a, c) => a + c.w, 0) + gap * (items.length - 1);
let x = cx - total / 2;
return items.map((c) => {
const s = [
`<rect class="chip chip--${c.v}" x="${x}" y="${y}" width="${c.w}" height="19" rx="9.5"/>`,
`<text class="chipt chipt--${c.v}" x="${x + c.w / 2}" y="${y + 13}" text-anchor="middle">${c.t}</text>`,
].join("\n ");
x += c.w + gap;
return s;
}).join("\n ");
};
return [
line(row1, top),
line(row2, top + 24),
`<text class="varcap" x="${cx}" y="${top + 62}" text-anchor="middle">RESOLVED $VARS</text>`,
].join("\n ");
}
function themeBlock(t, prefix = "") {
return `
${prefix}.frame { fill: ${t.frameBg}; stroke: ${t.frameBd}; }
${prefix}.card { fill: ${t.cardBg}; stroke: ${t.cardBd}; }
${prefix}.card--wp { stroke: ${t.node}; stroke-opacity: .55; }
${prefix}.ico--wp { color: ${t.node}; }
${prefix}.ico--ext { color: ${t.muted}; }
${prefix}.ico--wildcard { color: ${t.wildcard}; }
${prefix}.ico--fixed { color: ${t.fixed}; }
${prefix}.ico--combine { color: ${t.combine}; }
${prefix}.ico--derivation { color: ${t.derivation}; }
${prefix}.ico--constraint { color: ${t.constraint}; }
${prefix}.pill { fill: ${t.cardBg}; stroke: ${t.frameBd}; }
${prefix}.feeder { fill: ${t.muted}; }
${prefix}.nm { fill: ${t.text}; }
${prefix}.id, ${prefix}.lbl, ${prefix}.cap, ${prefix}.varcap { fill: ${t.dim}; }
${prefix}.ln { stroke: ${t.cardBd}; }
${prefix}.wash { stop-color: ${t.node}; }
${prefix}.flow0 { stop-color: ${t.cardBd}; }
${prefix}.head { fill: ${t.cardBd}; }
${prefix}.chip { fill: ${t.cardBg}; }
${prefix}.chip--a { stroke: ${t.chipAb}; stroke-opacity: .4; }
${prefix}.chip--b { stroke: ${t.chipBb}; stroke-opacity: .4; }
${prefix}.chip--c { stroke: ${t.chipCb}; stroke-opacity: .4; }
${prefix}.chipt--a { fill: ${t.chipA}; }
${prefix}.chipt--b { fill: ${t.chipB}; }
${prefix}.chipt--c { fill: ${t.chipC}; }`;
}
function shell(w, h, label, body) {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="${label}">
<style><![CDATA[
.frame, .card, .pill, .chip { stroke-width: 1; }
.cap { font: 400 10px ${SANS}; letter-spacing: .06em; }
.feeder { font: 400 10.5px ${SANS}; }
.nm { font: 600 12px ${SANS}; }
.id { font: 400 9.5px ${MONO}; }
.lbl, .varcap { font: 400 9.5px ${SANS}; letter-spacing: .05em; }
.chipt { font: 400 10.5px ${MONO}; }
${themeBlock(DARK)}
@media (prefers-color-scheme: light) {${themeBlock(LIGHT, " ")}
}
]]></style>
<defs>
<!-- Mirrors the SPA frame wash. The CSS is an ellipse 120 percent of the
width by 100 percent of the HEIGHT, anchored top-left — so it spans
the whole panel. Squashing it by the aspect ratio instead pins it to
the corner and leaves the rest looking flat. -->
<radialGradient id="wash" cx="0" cy="0" r="1" gradientTransform="scale(1.2 1)">
<stop class="wash" offset="0" stop-opacity="0.10"/>
<stop class="wash" offset="0.6" stop-opacity="0.02"/>
<stop class="wash" offset="1" stop-opacity="0"/>
</radialGradient>
<linearGradient id="flowv" x1="0" y1="0" x2="0" y2="1">
<stop class="flow0" offset="0" stop-opacity="0"/>
<stop class="flow0" offset="0.25" stop-opacity="1"/>
<stop class="flow0" offset="0.8" stop-opacity="1"/>
<stop class="flow0" offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="flow" x1="0" y1="0" x2="1" y2="0">
<stop class="flow0" offset="0" stop-opacity="0"/>
<stop class="flow0" offset="0.2" stop-opacity="1"/>
<stop class="flow0" offset="0.8" stop-opacity="1"/>
<stop class="flow0" offset="1" stop-opacity="0"/>
</linearGradient>
</defs>
<rect class="frame" x="0.5" y="0.5" width="${w - 1}" height="${h - 1}" rx="12"/>
<rect x="0.5" y="0.5" width="${w - 1}" height="${h - 1}" rx="12" fill="url(#wash)"/>
${body}
</svg>
`;
}
/* ── figure 1 — what the pipeline does ─────────────────────────────────── */
function buildIntro() {
const W = 880;
const H = 202;
const cx = W / 2;
const rowTop = 104;
const midY = rowTop + 33;
const ctx = card(112, rowTop, "WP Context", "rolls + resolves", "sitemap");
const asm = card(566, rowTop, "Prompt Assembler", '"A $style portrait…"', "align-left");
const clip = card(772, rowTop, "CLIP Encode", "conditioning", "bolt", "ext");
const varsCx = 348;
const body = [
`<text class="cap" x="${cx}" y="30" text-anchor="middle">MODULES INSIDE THE CONTEXT</text>`,
feeders(cx, 40),
`<g class="ico--ext">${icon("angle-down", cx, 78, 12)}</g>`,
ctx.svg,
connector(112 + ctx.w / 2 + 8, varsCx - 78, midY, "EMITS"),
varBag(varsCx, rowTop - 2),
connector(varsCx + 78, 566 - asm.w / 2 - 8, midY, "FILLS"),
asm.svg,
connector(566 + asm.w / 2 + 8, 772 - clip.w / 2 - 8, midY, "PROMPT"),
clip.svg,
].join("\n ");
return shell(
W,
H,
"Wildcard, Fixed Values, Combine, Derivation and Constraint modules resolve inside WP Context, which emits resolved dollar-variables that fill the WP Prompt Assembler template, which CLIP Encode turns into conditioning",
body,
);
}
/* ── figure 2 — looping a chain ────────────────────────────────────────────
* The old figure ran the assembled prompt straight into KSampler. It does not
* go there: text is encoded first and only conditioning reaches the sampler.
* Seeds are the one thing that DOES arrive directly, so they get their own
* lane BELOW the text path rather than cutting through it. */
function buildLoop() {
const W = 880;
const H = 286;
const cx = W / 2;
const r1 = 54;
const r2 = 158;
const mid1 = r1 + 33;
const mid2 = r2 + 33;
const laneY = 252;
const loop = card(112, r1, "WP Context Loop", "N iterations", "refresh");
const seeds = card(112, r2, "WP Seed List", "N seeds", "list");
const ctx = card(340, r1, "WP Context", "module stack", "sitemap");
const asm = card(566, r1, "Prompt Assembler", "N prompts", "align-left");
const clip = card(566, r2, "CLIP Encode", "N conditionings", "bolt", "ext");
const ks = card(772, r2, "KSampler", "N images", "image", "ext");
const body = [
`<text class="cap" x="${cx}" y="30" text-anchor="middle">BOTH LISTS FAN OUT IN LOCKSTEP — ITERATION N MEETS SEED N</text>`,
loop.svg, seeds.svg, ctx.svg, asm.svg, clip.svg, ks.svg,
connector(112 + loop.w / 2 + 8, 340 - ctx.w / 2 - 8, mid1, "CONTEXT"),
// WP_ContextLoop has a `loop_config` side-output and WP_SeedList takes it
// as an optional input, so the seed list mirrors the loop's count,
// strategy and base seed. That link is what makes the caption's "lockstep"
// true; without it the two read as unrelated sources.
`<text class="lbl" x="122" y="${(r1 + 66 + r2) / 2 + 4}">LOOP_CONFIG</text>`,
`<rect fill="url(#flowv)" x="111.2" y="${r1 + 66}" width="1.6" height="${r2 - 6 - (r1 + 66)}"/>`,
`<path class="head" d="M108 ${r2 - 6} L112 ${r2} L116 ${r2 - 6} Z"/>`,
connector(340 + ctx.w / 2 + 8, 566 - asm.w / 2 - 8, mid1, "$VARS"),
// assembler drops straight down into the encoder
`<text class="lbl" x="576" y="141">PROMPT</text>`,
`<rect fill="url(#flowv)" x="565.2" y="${r1 + 66}" width="1.6" height="${r2 - 6 - (r1 + 66)}"/>`,
`<path class="head" d="M562 ${r2 - 6} L566 ${r2} L570 ${r2 - 6} Z"/>`,
connector(566 + clip.w / 2 + 8, 772 - ks.w / 2 - 8, mid2, "COND"),
// seeds take their own lane under the text path and rise into the sampler
`<path class="ln" fill="none" d="M112 ${r2 + 66} V ${laneY} H 772 V ${r2 + 72}"/>`,
`<path class="head" d="M768 ${r2 + 72} L772 ${r2 + 66} L776 ${r2 + 72} Z"/>`,
`<text class="lbl" x="420" y="${laneY - 8}" text-anchor="middle">SEED[]</text>`,
].join("\n ");
return shell(
W,
H,
"WP Context Loop drives WP Context and the Prompt Assembler to produce N prompts, CLIP Encode turns them into conditioning for KSampler, and WP Seed List supplies the matching seed for each iteration directly to KSampler",
body,
);
}
fs.writeFileSync(path.join(OUT_DIR, "flow-intro.svg"), buildIntro());
fs.writeFileSync(path.join(OUT_DIR, "flow-loop.svg"), buildLoop());
console.log("wrote flow-intro.svg + flow-loop.svg");
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Build the booru tag list the optional value autocomplete reads.
Output is the four-column shape `wp_api/_tag_index.py` parses:
name,category,post_count,"alias,alias"
Run by `.github/workflows/tag-list.yml`, not at install time. The result ships
as a release asset — never in the repository (it regenerates, and a 6 MB file
per refresh would bloat history forever) and never in the package (a usable
list dwarfs the whole gzipped JS budget).
WHY A POST-COUNT FLOOR AND NOT A PAGE COUNT
-------------------------------------------
`search[order]=count` sorts strictly descending ACROSS pages, verified against
the live API on 2026-08-06:
page 1: first 8,244,950 last 55,918
page 100: first 68 last 67
page 800: first 1 last 1
So a popular tag cannot hide on a later page, and stopping once counts fall
below the floor provably loses nothing — it is an early exit, not a filter.
Walking all 1000 allowed pages spends ~900 requests and a quarter of an hour
collecting single-post tags nobody will ever autocomplete.
For reference, ComfyUI-Custom-Scripts ships ~100k tags, which lands around a
floor of 68.
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import defaultdict
BASE = "https://danbooru.donmai.us"
# MUST stay in bare `product/version` form. Danbooru sits behind a bot filter
# that 403s anything else, measured 2026-08-06:
#
# wildcard-pipeline-taglist/1.0 -> 200
# wp-tag-list/1.0 -> 200
# ComfyUI-Wildcard-Pipeline -> 403 (no version)
# ComfyUI-Wildcard-Pipeline/1.0 -> 403
# wp-tag-list/1.0 (+https://github.com/DumiFlex/...) -> 403 (URL present)
#
# So do NOT "improve" this by adding a contact URL or a longer description.
# That is the normal courtesy for a scraper and it is exactly what gets this
# blocked; the version suffix is what makes it acceptable.
USER_AGENT = "wildcard-pipeline-taglist/1.0"
PAGE_LIMIT = 1000
# The API refuses page > 1000 outright (HTTP 410, "You cannot go beyond page
# 1000"), so this is the ceiling whatever the floor says.
MAX_PAGE = 1000
def fetch(path: str, params: dict[str, str], *, retries: int = 4) -> list[dict]:
"""GET a JSON array, retrying on transient failures.
The original script this replaces aborted on the first non-200, which for
a 100-request run means one blip discards everything collected so far.
"""
url = f"{BASE}{path}?{urllib.parse.urlencode(params)}"
delay = 2.0
for attempt in range(retries):
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as err:
# 410 is the deep-pagination refusal: retrying cannot help.
if err.code == 410:
return []
if err.code not in (429, 500, 502, 503, 504) or attempt == retries - 1:
raise
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
if attempt == retries - 1:
raise
print(f" retry in {delay:.0f}s ...", flush=True)
time.sleep(delay)
delay *= 2
return []
def fetch_tags(min_count: int, pause: float) -> list[dict]:
"""Tags ordered by post count, stopping once they fall below `min_count`."""
out: list[dict] = []
for page in range(1, MAX_PAGE + 1):
rows = fetch("/tags.json", {
"limit": str(PAGE_LIMIT),
"search[hide_empty]": "yes",
"search[is_deprecated]": "no",
"search[order]": "count",
"page": str(page),
})
if not rows:
print(f" page {page}: empty, stopping", flush=True)
break
kept = [r for r in rows if r.get("post_count", 0) >= min_count]
out.extend(kept)
print(
f" page {page:>4}: +{len(kept):>4} "
f"(lowest {rows[-1].get('post_count', 0):,}) total {len(out):,}",
flush=True,
)
if len(kept) < len(rows):
# Descending order means everything after this point is below the
# floor too. Nothing worth having is left.
print(f" reached the floor of {min_count} posts", flush=True)
break
time.sleep(pause)
return out
def fetch_aliases(pause: float, max_pages: int) -> dict[str, list[str]]:
"""`canonical -> [alias, ...]`, from the separate aliases endpoint.
`tags.json` carries no aliases, and they are what let someone type `hires`
and land on `highres` — the single most valuable thing in the file after
the names themselves.
"""
mapping: dict[str, list[str]] = defaultdict(list)
for page in range(1, max_pages + 1):
rows = fetch("/tag_aliases.json", {
"limit": str(PAGE_LIMIT),
"search[status]": "active",
"page": str(page),
})
if not rows:
break
for row in rows:
antecedent = row.get("antecedent_name")
consequent = row.get("consequent_name")
if isinstance(antecedent, str) and isinstance(consequent, str):
mapping[consequent].append(antecedent)
print(f" aliases page {page:>3}: {len(mapping):,} tags covered", flush=True)
time.sleep(pause)
return mapping
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"--min-count", type=int, default=20,
help="stop once tags drop below this many posts (default: 20)",
)
ap.add_argument("--out", default="wildcard-pipeline-tags.csv")
ap.add_argument(
"--pause", type=float, default=1.0,
help="seconds between requests (default: 1.0)",
)
ap.add_argument(
"--alias-pages", type=int, default=60,
help="pages of aliases to fetch (default: 60)",
)
ap.add_argument("--no-aliases", action="store_true")
args = ap.parse_args()
print(f"Fetching tags with at least {args.min_count} posts ...", flush=True)
tags = fetch_tags(args.min_count, args.pause)
if not tags:
print("No tags fetched — refusing to write an empty list.", file=sys.stderr)
return 1
aliases: dict[str, list[str]] = {}
if not args.no_aliases:
print("Fetching aliases ...", flush=True)
aliases = fetch_aliases(args.pause, args.alias_pages)
with open(args.out, "w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
for tag in tags:
name = tag.get("name")
if not isinstance(name, str) or not name:
continue
writer.writerow([
name,
tag.get("category", 0),
tag.get("post_count", 0),
",".join(aliases.get(name, [])),
])
print(
f"\nWrote {len(tags):,} tags to {args.out} "
f"({sum(len(v) for v in aliases.values()):,} aliases)",
flush=True,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+134 -10
View File
@@ -157,14 +157,136 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
// parity test) now; the upcoming reach/constraint-matrix UI phases need the
// total budget headroom in the lazy ContextWidget chunk. Approved alongside
// the SP3 constraint feature.
// Bumped 430 -> 431 KB on 2026-06-18 for the editor robustness pass:
// RichTextInput's scoped renderEpoch rebuild (nested-ref picker crash fix)
// + useEditorDraft flush-on-unmount/unload + ErrorBoundary copy, plus the
// clipboard-shield wired into _shared.ts + main.ts (Ctrl-A/C/V/X guard on
// editable widget inputs). ~0.3 KB net gzip across the extension entry +
// shared chunks. Landed on the bulk-editor branch (the bulk UI itself is
// manager-only and adds nothing to js/).
const TOTAL_LIMIT = 431 * 1024; // 431 KB
// Bumped 430 -> 431 KB on 2026-06-18 — two same-day landings share the
// ceiling (both CSS/glue; the manager-only bulk UI adds nothing to js/):
// - editor robustness: RichTextInput renderEpoch crash fix + useEditorDraft
// flush-on-unmount/unload + ErrorBoundary copy + clipboard-shield wired
// into _shared.ts / main.ts (Ctrl-A/C/V/X guard on editable inputs).
// - instance-modal scroll/sticky: sticky header + runtime/footer dock +
// own-scrollbar inner lists (options/values/rules/exceptions) + compact
// exceptions trash, across all 6 edit modals.
// Bumped 431 -> 432 KB on 2026-06-18 for the syntax + UI fix batch: inline
// `{N::a|b}` branch weights (engine + TS resolver mirror), the filter / pick /
// exclude-null MOD-dot fix, the "X of Y options" + "picks N-M" wildcard
// subtitle, the library-aware nested-ref check (kills the false BROKEN REF),
// and the constraint source-missing debug warning + label. ~0.4 KB net gzip
// across the ContextWidget + conflicts chunks; entry stays put.
// Bumped 432 -> 435 KB on 2026-06-22 for the canvas constraint-matrix grouping
// parity. MatrixSection (the ComfyUI instance-modal grid) gains the SPA's
// grouped-axis layout — column bands, row header chips, solo eyebrow, the
// labelled "uncategorized" bucket, and frozen headers + both-axis scroll —
// 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.
// 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.
// 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.
// Bumped 449 -> 450 KB on 2026-06-28 for the row context-menu Hold toggle.
// Adds HOLD_KINDS + isHoldable + toggleHold to ContextWidget plus the quick
// "Hold across run" / "Release hold" menu item (2-click seed_scope toggle,
// base-only with a frame-active disabled hint) in the lazy ContextWidget
// chunk; entry stays put. Measured 459909 bytes (133 over the 449 ceiling);
// minimal bump.
// Bumped 450 -> 453 KB on 2026-07-21 for loop bypass frames (#13): the
// `bypass_frames` config field + the SeedLockRow bypass toggle, the
// SeedListModal opt-in bypass column, and the ContextLoopWidget "N bypassed"
// badge. Config field alone landed 34 bytes over 450; the ~3 KB headroom
// covers the UI additions in the same feature.
// Bumped 453 -> 461 KB on 2026-07-23 for the chip-UX + library-linkage feature
// set (issues #1-#9). Workstream B (RefChip hover card: uuid + filter reads-as
// + live "N of M options match" from the extended preview-resolver) landed the
// canvas within 10 bytes of the 453 ceiling; the +8 KB gives the remaining
// workstreams D (library re-link UI) and E (nested-ref propagation) room in the
// same approved feature set. Explicitly approved.
// Bumped 461 -> 468 KB on 2026-07-30 for the QoL sweep (P0+P1). The bulk is UI
// that had to grow: export/import row detail per entity kind, the category
// IconPicker, the feedback modal, cross-bucket search, collapsible tag pools,
// and finally `collectUpstreamProducers` + the var-chip producer card, which
// tipped it 180 bytes over. Explicitly approved by the maintainer, who asked
// for the budget to be raised as needed rather than features trimmed to fit.
// Bumped 468 -> 490 KB on 2026-08-02 for the `@`/`$` autocomplete + sub-category
// filter redesign. The popover rows carry structure now (icon, uuid, option /
// axis / tag facts, and for `$` the producer line and override badge) instead
// of one line of text, and the shared filter panel that follows adds the
// axis-grouped palette with collapse + search, the live match count and the
// zero-match / unknown-term states. Sized with headroom deliberately: the
// previous bump left 179 bytes free, which meant every subsequent change had
// to argue with the gate. Explicitly approved by the maintainer, who asked for
// the budget to be raised as needed rather than features trimmed to fit.
// LOWERED 490 -> 420 KB on 2026-08-06, after the webfonts stopped being bundled.
// Vite library mode ignores `assetsInlineLimit` and base64-inlined both woff2
// files into a JS chunk; they are now copied to js/assets/fonts/ and <link>ed at
// runtime (see src/extension/fonts.ts). Measured 492,407 -> 403,055 bytes.
//
// Tightened rather than banked, because the gate is what stops it silently
// regressing: re-inlining the fonts would add ~90 KB, which fits under 490 KB
// and would have passed unnoticed. It does not fit under 420 KB. The 17 KB of
// headroom left is deliberate — enough for ordinary work, not enough to hide a
// whole asset class coming back.
// RAISED 420 -> 424 KB on 2026-10-02 for the wildcard fallback option (canvas
// badge + Debug note): dev sat at 429,971 bytes, 109 under the gate, and the
// feature needed ~200. Approved by the maintainer ("you are allowed to
// increase the bundle budget if necessary"). 4 KB leaves room for a few more
// small features while a re-inlined font (~90 KB) still trips the gate.
// RAISED 424 -> 428 KB on 2026-10-05 for Context Loop sweep mode (Sweep
// panel in the loop widget chunk, downstream-wildcard walk in boot): dev sat
// at 430,134 bytes and the feature adds ~4.8 KB. The maintainer treats this
// budget as a guide to raise when a needed feature does not fit.
// RAISED 428 -> 436 KB on 2026-10-06 for model-aware pipelines (WP Model Info
// walker branches + variant rules + its widget glue in boot, the Model Info
// widget chunk with pins, Assembler toggles and the shared row-primitive
// buttons, Match variable control in the wildcard modal chunk, LoRA by model
// preset): the feature adds ~6 KB and tipped dev ~3.2 KB over. Same "guide,
// not a cap" rule as above.
// RAISED 436 -> 440 KB on 2026-10-06 for the constraint "rules out all"
// warning (the constraint factor math and wildcard pool helpers now reach the
// ContextWidget chunk): dev sat at 446,313 of 446,464 bytes and the check adds
// ~2.2 KB. Same "guide, not a cap" rule as above.
// RAISED 440 -> 452 KB on 2026-10-07 for WP Image Filter (pick-and-pass
// node): its picker modal (~7.6 KB) and node widget (~3.4 KB) are lazy
// chunks, plus ~1.5 KB of event + widget glue in boot. dev sat at 448,610
// bytes. Same "guide, not a cap" rule as above.
// RAISED 452 -> 464 KB on 2026-10-07 for the Image Filter refine step
// (frame labels, sweep grid, compare, prompt edits, mask painter, Send to
// Loop): the picker chunk grows ~8 KB and stays lazy. The branch sat at
// 462,555 bytes before it. Same "guide, not a cap" rule as above.
// RAISED 464 -> 468 KB on 2026-10-07 for the zoom's Details panel and the
// frame-click zoom (lazy picker chunk); the branch sat at 476,239 bytes.
const TOTAL_LIMIT = 468 * 1024; // 468 KB
function gzipSize(path) {
return gzipSync(readFileSync(path)).length;
@@ -176,7 +298,7 @@ function allJsFiles(dir) {
const full = join(dir, name);
if (statSync(full).isDirectory()) {
out.push(...allJsFiles(full));
} else if (name.endsWith(".js")) {
} else if (name.endsWith(".js") || name.endsWith(".mjs")) {
out.push(full);
}
}
@@ -198,7 +320,9 @@ for (const file of files) {
// content edit and would otherwise show every chunk as added/removed.
// Normalize Windows backslashes so the manifest diffs cleanly against
// CI runs (which always emit forward slashes on Linux).
const stable = rel.replace(/-[A-Za-z0-9_-]{8,}\.js$/, ".js").replaceAll("\\", "/");
// Lazy chunks are `.mjs` (see vite.config.mts); report them under `.js` so
// manifests from before that switch still diff chunk-for-chunk.
const stable = rel.replace(/-[A-Za-z0-9_-]{8,}\.m?js$/, ".js").replaceAll("\\", "/");
manifest.files[stable] = size;
console.log(` ${rel.padEnd(40)} ${(size / 1024).toFixed(2).padStart(7)} KB`);
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
// Copy the webfont directory verbatim from src/assets/fonts/ to
// js/assets/fonts/, so ComfyUI serves it under
// /extensions/<package>/assets/fonts/.
//
// These files deliberately do NOT go through Vite. Library mode always
// base64-inlines assets — `build.assetsInlineLimit` is documented as ignored
// when `build.lib` is set — so a woff2 reachable from the module graph ends up
// ~33% larger, inside a JS chunk, parsed as JavaScript. Copying sidesteps the
// bundler entirely; `src/extension/fonts.ts` <link>s the stylesheet at runtime
// and the browser resolves the woff2 URLs relative to it.
//
// Run as part of `pnpm build:extension`, alongside copy-help-docs.mjs.
import { readdirSync, mkdirSync, copyFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("..", import.meta.url));
const SRC = join(ROOT, "src", "assets", "fonts");
const DST = join(ROOT, "js", "assets", "fonts");
try {
rmSync(DST, { recursive: true, force: true });
mkdirSync(DST, { recursive: true });
const files = readdirSync(SRC);
if (!files.some((f) => f.endsWith(".css"))) {
// A missing stylesheet is silent at runtime — the <link> 404s and text
// quietly falls back to the system stack. Fail the build instead.
throw new Error(`no stylesheet in ${SRC}; fonts.ts expects wp-fonts.css`);
}
for (const name of files) {
copyFileSync(join(SRC, name), join(DST, name));
console.log(` src/assets/fonts/${name} → js/assets/fonts/${name}`);
}
console.log(`Copied ${files.length} font file(s).`);
} catch (err) {
console.error("[copy-fonts] failed:", err);
process.exit(1);
}
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
/**
* Post a release announcement to Discord.
*
* Derives the message from the release notes that already exist — there is
* nothing extra to write. Run by `.github/workflows/discord-announce.yml` when
* a release is published, and runnable by hand for the catch-up case.
*
* Usage:
* node scripts/discord-announce.mjs --version v2.12.0 # dry run
* node scripts/discord-announce.mjs --version v2.12.0 --post # → staff
* node scripts/discord-announce.mjs --version v2.12.0 --post --channel public
* node scripts/discord-announce.mjs --since v2.10.2 --post # catch-up
* node scripts/discord-announce.mjs --notes-file docs/release-notes/next.md \
* --version v2.12.0 # preview
*
* DRY RUN IS THE DEFAULT. `--post` is required to send anything, so previewing
* an announcement can never accidentally publish one.
*
* Environment:
* DISCORD_WEBHOOK_STAFF staff / pre-announcement channel (the default)
* DISCORD_WEBHOOK_PUBLIC public announcement channel (--channel public only)
* DISCORD_WEBHOOK_URL legacy fallback for the staff webhook
* DISCORD_ROLE_ID optional; pinged as <@&ID>, PUBLIC channel only
* DISCORD_USERNAME optional; overrides the posting name
* DISCORD_AVATAR_URL optional; overrides the posting avatar (raster only)
* GITHUB_TOKEN optional; raises the API rate limit for --since
*/
import { readFileSync } from "node:fs";
import { buildPayload, buildRollupPayload, normalizeRoleId } from "./lib/discord-notes.mjs";
const REPO = "DumiFlex/ComfyUI-Wildcard-Pipeline";
/**
* Two channels, deliberately asymmetric.
*
* `staff` posts automatically on every release and carries a link to publish
* the same message onward — it is the review step. `public` only ever runs
* from a deliberate manual dispatch, so a release can never announce itself to
* everyone without someone having read it first.
*/
const CHANNELS = {
staff: ["DISCORD_WEBHOOK_STAFF", "DISCORD_WEBHOOK_URL"],
public: ["DISCORD_WEBHOOK_PUBLIC"],
};
function webhookFor(channel) {
for (const name of CHANNELS[channel]) {
if (process.env[name]) return { url: process.env[name], via: name };
}
return { url: null, via: CHANNELS[channel][0] };
}
function arg(name, fallback = undefined) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
const next = process.argv[i + 1];
return next && !next.startsWith("--") ? next : true;
}
function ghHeaders() {
const h = { Accept: "application/vnd.github+json", "User-Agent": "wp-discord-announce" };
if (process.env.GITHUB_TOKEN) h.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
return h;
}
async function fetchRelease(tag) {
const url = tag
? `https://api.github.com/repos/${REPO}/releases/tags/${tag}`
: `https://api.github.com/repos/${REPO}/releases/latest`;
const res = await fetch(url, { headers: ghHeaders() });
if (!res.ok) throw new Error(`GitHub ${res.status} fetching ${tag ?? "latest"}`);
return res.json();
}
/** Releases newer than `sinceTag`, newest first. Drafts and prereleases skipped. */
async function fetchReleasesSince(sinceTag) {
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=100`, {
headers: ghHeaders(),
});
if (!res.ok) throw new Error(`GitHub ${res.status} listing releases`);
const all = (await res.json()).filter((r) => !r.draft && !r.prerelease);
const idx = all.findIndex((r) => r.tag_name === sinceTag);
if (idx === -1) throw new Error(`No release tagged ${sinceTag}`);
return all.slice(0, idx);
}
async function main() {
const post = arg("post", false) === true;
const channel = arg("channel", "staff");
if (channel !== "staff" && channel !== "public") {
console.error(`Unknown --channel "${channel}". Use staff or public.`);
process.exit(1);
}
// Belt to the workflow's braces. The workflow only passes `--channel public`
// on a manual dispatch, but a release event reaching this line with public
// selected would announce to everyone with nobody having read it.
if (channel === "public" && process.env.GITHUB_EVENT_NAME === "release") {
console.error("Refusing to post to the public channel from a release event.");
process.exit(1);
}
// Only the public post pings. A role ping in the staff channel on every
// release is noise in a room that already exists to watch releases.
const rawRole = channel === "public" ? process.env.DISCORD_ROLE_ID : undefined;
const roleId = normalizeRoleId(rawRole);
// `buildPayload` drops a malformed id either way, but silence would leave
// someone wondering why the ping never fired.
if (rawRole && rawRole.trim() && !roleId) {
console.warn(
`DISCORD_ROLE_ID is not a valid role id (${JSON.stringify(rawRole)}) — posting without a ping.`,
);
}
// Undefined falls through to the defaults in discord-notes.mjs — an empty
// env var must not blank the identity and hand the post back to the
// webhook's own name.
const username = process.env.DISCORD_USERNAME || undefined;
const avatarUrl = process.env.DISCORD_AVATAR_URL || undefined;
const since = arg("since");
const notesFile = arg("notes-file");
let version = arg("version");
let payload;
if (typeof since === "string") {
const releases = await fetchReleasesSince(since);
if (!releases.length) {
console.error(`Nothing published after ${since} — nothing to announce.`);
process.exit(0);
}
payload = buildRollupPayload({
releases: releases.map((r) => ({ version: r.tag_name, body: r.body ?? "" })),
sinceVersion: since,
releaseUrl: releases[0].html_url,
roleId,
username,
avatarUrl,
channel,
});
} else if (typeof notesFile === "string") {
// Local preview against unreleased notes. No network, so the compare link
// is unknown and simply omitted.
payload = buildPayload({
version: version || "(unreleased)",
body: readFileSync(notesFile, "utf8"),
roleId,
username,
avatarUrl,
channel,
});
} else {
const rel = await fetchRelease(typeof version === "string" ? version : undefined);
version = rel.tag_name;
payload = buildPayload({
version,
body: rel.body ?? "",
releaseUrl: rel.html_url,
roleId,
username,
avatarUrl,
channel,
});
}
if (!post) {
console.log("--- DRY RUN (pass --post to send) ---\n");
// Identity is part of what a preview is for: it is the thing that was
// wrong on the first real post, and it is invisible in the embed body.
console.log(`channel: ${channel}`);
console.log(`posting as: ${payload.username}`);
console.log(`avatar: ${payload.avatar_url}`);
console.log("");
console.log(payload.embeds[0].title);
console.log("-".repeat(60));
console.log(payload.embeds[0].description);
console.log("-".repeat(60));
console.log(`${payload.embeds[0].description.length} / 4096 chars`);
return;
}
const { url: webhook, via } = webhookFor(channel);
if (!webhook) {
console.error(`${via} is not set — refusing to post to the ${channel} channel.`);
process.exit(1);
}
const res = await fetch(`${webhook}?wait=true`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
console.error(`Discord ${res.status}: ${await res.text()}`);
process.exit(1);
}
console.log(`Announced ${payload.embeds[0].title} → ${channel}`);
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});
+31 -6
View File
@@ -61,22 +61,36 @@ _SAMPLES: dict[str, tuple[type, dict]] = {
{
"var_binding": "mood",
"sub_categories": ["soft", "bold"],
# Tag axes: an `accepts` group must survive the round-trip, so the
# sample carries both the group map and its kind. A stripped
# tag_group_kinds silently degrades the axis to classify.
"tag_groups": {"TEXTURE": ["soft", "bold"]},
"tag_group_kinds": {"TEXTURE": "accepts"},
"options": [
{"id": "opt00001", "value": "serene", "weight": 1, "sub_category": "soft"},
# `negative` (send-to-negative, v8) must survive the
# strict validator like the value does.
{"id": "opt00001", "value": "serene", "weight": 1, "sub_categories": ["soft"],
"negative": "angry, tense"},
# Null option: the is_null contract. Must round-trip
# through the validator or install rebuilds it as an
# invalid empty-value option.
{"id": "opt00002", "value": "", "weight": 1, "sub_category": None, "is_null": True},
{"id": "opt00002", "value": "", "weight": 1, "sub_categories": [], "is_null": True},
# Fallback (v9): the flag must survive the strict validator,
# or a shared pack's fallback rolls like any other option.
{"id": "opt00003", "value": "calm", "weight": 1, "sub_categories": [],
"fallback": True},
],
},
),
"fixed_values": (
FixedValuesHandler,
{"values": [{"id": "val00001", "name": "style", "value": "oil painting"}]},
{"values": [{"id": "val00001", "name": "style", "value": "oil painting",
"negative": "photo, 3d render"}]},
),
"combine": (
CombineHandler,
{"template": "$mood $subject", "output_var": "scene", "input_vars": ["mood", "subject"]},
{"template": "$mood $subject", "output_var": "scene", "input_vars": ["mood", "subject"],
"negative": "cropped"},
),
"derivation": (
DerivationHandler,
@@ -93,12 +107,20 @@ _SAMPLES: dict[str, tuple[type, dict]] = {
"target_var": "accent", "mode": "replace",
"value": "cinematic lighting",
},
# THEN ... AND ... (v8): runs after `action`.
"extra_actions": [
{
"target_var": "accent", "mode": "negative",
"value": "$mood.neg",
},
],
},
],
"else": {
# "Add to negative" (v8): writes no value.
"action": {
"target_var": "accent", "mode": "replace",
"value": "soft lighting",
"target_var": "accent", "mode": "negative",
"value": "harsh shadows",
},
},
},
@@ -112,6 +134,9 @@ _SAMPLES: dict[str, tuple[type, dict]] = {
"target_wildcard_id": "tgt00001",
"exceptions": [],
"matrix": {"soft": {"bold": {"mode": "boost", "factor": 3}}},
# SP3 reach selector — must survive the round-trip, not strip back
# to the {mode: "all"} default.
"target_select": {"mode": "next", "count": 2},
},
),
}
+301
View File
@@ -0,0 +1,301 @@
/**
* Release notes → Discord message.
*
* The announcement is DERIVED, never authored. `docs/release-notes/next.md` is
* already written by hand for every release and already is the GitHub release
* body — so the only reason a Discord post was a separate chore is that nothing
* transformed one into the other. Nothing here asks for new prose.
*
* Discord is not a markdown renderer in the way GitHub is, and the gaps are
* specific rather than general:
*
* - Masked links `[text](url)` render ONLY inside an embed. In plain message
* content they show as literal brackets, which is why the notes go in an
* embed and the message content stays a bare ping.
* - There are no tables. A pipe table degrades into unreadable pipes.
* - `<details>` is HTML; it shows as raw tags.
* - Embed description caps at 4096 characters, and the whole payload at 6000.
*/
/** Everything below this marker is the long tail; the SPA modal cuts here too. */
const MODAL_CUT = /<!--\s*\/modal\s*-->/i;
/** Discord's hard cap on a single embed description. */
export const EMBED_DESCRIPTION_LIMIT = 4096;
/** Accent purple, matching `--wp-accent-500` in the manager. */
export const BRAND_COLOR = 0x8b5cf6;
/**
* Who the post appears to be from.
*
* A webhook posts under whatever name and avatar it was created with — the
* Discord default is a generic name like "Captain Hook" and a placeholder
* image, which is what an announcement went out as. `username` and
* `avatar_url` override that PER MESSAGE, so the identity lives here rather
* than in a webhook's settings: it stays right if the webhook is ever rotated
* or recreated, and it is the same in every channel the automation posts to.
*/
export const WEBHOOK_USERNAME = "Wildcard Pipeline";
/**
* Discord fetches this server-side, so it has to be a public raster URL — an
* SVG will not render, which rules out the repo's `favicon.svg`. Pinned to
* `main` rather than a tag so replacing the logo does not need a code change.
*/
export const WEBHOOK_AVATAR_URL =
"https://raw.githubusercontent.com/DumiFlex/ComfyUI-Wildcard-Pipeline/main/public/images/web-app-manifest-512x512.png";
/**
* A role id we are willing to turn into a ping, or `undefined`.
*
* An unset secret was always safe, but a MALFORMED one was not: any truthy
* string went straight into `<@&…>`, so a stray space produced a literal
* `<@& >` at the top of the announcement and a pasted mention produced
* `<@&<@&123>>`. Both render as visible junk in the most public message the
* project sends.
*
* Accepts a bare snowflake or the `<@&123>` form, since "Copy ID" and copying
* the mention text are equally easy mistakes to make. Anything else is
* rejected rather than guessed at — a ping that silently does not happen is a
* much smaller problem than a broken one that does.
*/
export function normalizeRoleId(raw) {
if (typeof raw !== "string" && typeof raw !== "number") return undefined;
const trimmed = String(raw).trim();
if (!trimmed) return undefined;
const unwrapped = trimmed.replace(/^<@&(\d+)>$/, "$1");
// Discord snowflakes are 17-20 digits today; the range leaves room without
// accepting arbitrary numbers.
return /^\d{17,20}$/.test(unwrapped) ? unwrapped : undefined;
}
/** Where the manual "publish to the public channel" run is started. */
export const DISPATCH_URL =
"https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/actions/workflows/discord-announce.yml";
/**
* Split a release body into the part worth announcing and what is left behind.
*
* The cut is the same one the in-app update dialog makes, for the same reason:
* an announcement is an interruption and should stay short. The tail is not
* discarded silently — `tailCount` lets the message say how much it is not
* showing, so a big release does not read as a small one.
*/
export function splitBody(md) {
const at = String(md ?? "").search(MODAL_CUT);
if (at === -1) return { head: String(md ?? ""), tail: "", tailCount: 0 };
const head = md.slice(0, at);
// Count against comment-stripped text, NOT details-stripped text. The
// "Also in this release" bullets live INSIDE the <details> block, so
// stripping it first counts nothing real — while the authoring guidance in
// the trailing HTML comment contains bullets of its own that must not be
// counted. Getting this backwards reported a 26-change release as 3.
const tail = stripComments(md.slice(at));
const tailCount = (tail.match(/^\s*[-*]\s+\S/gm) ?? []).length;
return { head, tail, tailCount };
}
function stripDetails(md) {
return md.replace(/<details[\s\S]*?<\/details>/gi, "");
}
function stripComments(md) {
return md.replace(/<!--[\s\S]*?-->/g, "");
}
/**
* Drop the header block the GitHub template prepends.
*
* It is a row of links to the wiki, Discord and the issue tracker. On Discord
* the Discord link is noise, and the rest belong in the embed's own fields
* rather than buried in the body.
*/
function stripTemplateHeader(md) {
return md
.replace(/^\s*##\s*🎉[^\n]*\n/, "")
.replace(/^\s*(?:📖|💬|📦|🐛)[^\n]*\n/gm, "");
}
/** Pipe tables have no Discord equivalent; each row becomes a bullet. */
function tablesToBullets(md) {
const lines = md.split("\n");
const out = [];
for (const line of lines) {
const isRow = /^\s*\|.*\|\s*$/.test(line);
if (!isRow) { out.push(line); continue; }
// Separator row (|---|---|) carries no content.
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
const cells = line.trim().slice(1, -1).split("|").map((c) => c.trim());
if (!cells.some(Boolean)) continue;
out.push(`- ${cells.filter(Boolean).join(" — ")}`);
}
return out.join("\n");
}
/**
* Headings become bold lines.
*
* Discord does render `#`-style headings now, but at sizes chosen for chat
* rather than for a dense embed — an `##` inside an embed is larger than the
* embed title above it. Bold keeps the hierarchy legible without competing.
*/
function headingsToBold(md) {
return md.replace(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm, (_m, text) => `**${text.trim()}**`);
}
/** Horizontal rules have no embed equivalent and read as stray dashes. */
function stripRules(md) {
return md.replace(/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/gm, "");
}
/** Collapse the blank lines the earlier passes leave behind. */
function tidy(md) {
return md.replace(/\n{3,}/g, "\n\n").replace(/[ \t]+$/gm, "").trim();
}
/**
* Truncate at a bullet boundary rather than mid-sentence.
*
* A hard slice at 4096 can cut inside a link or a bold run, which Discord then
* renders as broken markup — worse than saying less.
*/
export function clampToLimit(text, limit = EMBED_DESCRIPTION_LIMIT, suffix = "\n\n…") {
if (text.length <= limit) return text;
const room = limit - suffix.length;
const slice = text.slice(0, room);
const cut = Math.max(slice.lastIndexOf("\n- "), slice.lastIndexOf("\n\n"));
return (cut > room * 0.5 ? slice.slice(0, cut) : slice).trimEnd() + suffix;
}
/** Full markdown → Discord-embed markdown. */
export function toDiscordMarkdown(md) {
return tidy(
stripRules(
headingsToBold(
tablesToBullets(
stripTemplateHeader(stripComments(stripDetails(String(md ?? "")))),
),
),
),
);
}
/**
* Build the description for one release.
*
* `tailCount` is appended rather than dropped: a release whose highlights are
* three bullets and whose tail is forty changes should not look like a quiet
* week.
*/
export function buildDescription(body, { compareUrl } = {}) {
const { head, tailCount } = splitBody(body);
let out = toDiscordMarkdown(head);
if (tailCount > 0) {
const line = `*…plus ${tailCount} smaller change${tailCount === 1 ? "" : "s"}*`;
out += `\n\n${compareUrl ? `[${line.slice(1, -1)}](${compareUrl})` : line}`;
}
return clampToLimit(out);
}
/**
* One release → a Discord webhook payload.
*
* `allowed_mentions` is always explicit. A webhook with no restriction will
* happily resolve an `@everyone` that happened to appear in release notes, and
* the notes are not written with that in mind.
*/
export function buildPayload({
version,
body,
releaseUrl,
compareUrl,
roleId: rawRoleId,
username = WEBHOOK_USERNAME,
avatarUrl = WEBHOOK_AVATAR_URL,
channel = "staff",
installHint = 'ComfyUI Manager → search "Wildcard Pipeline" → Update → restart ComfyUI',
}) {
const description = buildDescription(body, { compareUrl });
// Normalised HERE rather than at the call site so every caller — CLI,
// roll-up, future ones — gets the same guarantee.
const roleId = normalizeRoleId(rawRoleId);
return {
username,
avatar_url: avatarUrl,
content: roleId ? `<@&${roleId}>` : "",
allowed_mentions: { parse: [], roles: roleId ? [roleId] : [] },
embeds: [
{
title: `Wildcard Pipeline ${version}`,
url: releaseUrl || undefined,
description,
color: BRAND_COLOR,
fields: reviewFields(channel),
footer: { text: installHint },
},
],
};
}
/**
* The staff copy carries its own publish button; the public copy does not.
*
* This is what makes the staff channel a review step rather than a duplicate
* feed: the post you are reading is the post that will go out, and the link to
* send it is attached to it. Without that, "go and publish it" is a separate
* instruction living in someone's head.
*
* Returned as `undefined` for the public channel rather than an empty array —
* Discord rejects `fields: []` on some client versions, and an empty field
* block would render as dead space in the announcement everyone sees.
*/
function reviewFields(channel) {
if (channel !== "staff") return undefined;
return [
{
name: "Ready to go public?",
value:
`[Run the workflow](${DISPATCH_URL}) with **channel: public** and ` +
"**dry_run: false** to post this to the announcement channel.",
},
];
}
/**
* Several releases → one catch-up payload.
*
* For the case that actually happens: announcements skipped for a few versions,
* then one post covering the gap. Each release keeps its own heading so readers
* can tell which version brought what, and the newest goes first because that is
* the one people are deciding whether to install.
*
* `releases` is newest-first: `{ version, body }`.
*/
export function buildRollupPayload({
releases, sinceVersion, releaseUrl, roleId, installHint, username, avatarUrl,
channel = "staff",
}) {
const latest = releases[0];
const sections = releases.map((r) => {
const { head } = splitBody(r.body);
const text = toDiscordMarkdown(head);
return `**${r.version}**\n${text}`;
});
const joined = sections.join("\n\n");
const base = buildPayload({
version: latest?.version ?? "",
body: "",
releaseUrl,
roleId,
installHint,
username,
avatarUrl,
channel,
});
base.embeds[0].title = sinceVersion
? `Wildcard Pipeline ${latest?.version ?? ""} — everything since ${sinceVersion}`
: `Wildcard Pipeline ${latest?.version ?? ""}`;
base.embeds[0].description = clampToLimit(joined);
return base;
}
+348
View File
@@ -0,0 +1,348 @@
import { describe, it, expect } from "vitest";
import {
DISPATCH_URL,
normalizeRoleId,
splitBody,
toDiscordMarkdown,
buildDescription,
buildPayload,
buildRollupPayload,
clampToLimit,
EMBED_DESCRIPTION_LIMIT,
} from "./discord-notes.mjs";
/**
* The announcement is derived from notes that already exist, so the risk is not
* "does it write good copy" — it is "does it mangle copy that was already
* fine". Most of these pin a Discord-specific rendering gap.
*/
const REAL_SHAPE = `## 🎉 Wildcard Pipeline v2.11.0
📖 [Docs (wiki)](https://x/wiki) · 💬 [Discord](https://discord.gg/x) · 📦 [Install](https://x/q) · 🐛 [Issues](https://x/i)
### ✨ What's new
A correctness fix for held modules, plus tools for reconnecting workflow items.
- **Held modules stay held.** A module set to **Hold across run** could re-roll.
- **Search by id.** Pasting an 8-character id now finds the item.
<!-- /modal -->
### Also in this release
- Wire collapse survives a live renderer switch
- Modal keystrokes no longer escape
---
**Full changelog:** [v2.10.2…v2.11.0](https://x/compare)
`;
describe("splitBody", () => {
it("cuts at the modal marker and counts what is left behind", () => {
const { head, tailCount } = splitBody(REAL_SHAPE);
expect(head).toContain("Held modules stay held");
expect(head).not.toContain("Also in this release");
expect(tailCount).toBe(2);
});
it("treats a body with no marker as all highlights", () => {
const { head, tailCount } = splitBody("- one\n- two");
expect(head).toBe("- one\n- two");
expect(tailCount).toBe(0);
});
it("survives an empty or missing body", () => {
expect(splitBody("").head).toBe("");
expect(splitBody(undefined).head).toBe("");
});
});
describe("toDiscordMarkdown", () => {
it("drops the GitHub template header — the Discord link is noise on Discord", () => {
const out = toDiscordMarkdown(REAL_SHAPE);
expect(out).not.toContain("🎉 Wildcard Pipeline");
expect(out).not.toContain("discord.gg");
expect(out).not.toContain("Docs (wiki)");
});
it("keeps masked links, which DO render inside an embed", () => {
expect(toDiscordMarkdown("see [the docs](https://x/d)")).toContain("[the docs](https://x/d)");
});
it("turns headings into bold — an embed h2 outsizes the embed title", () => {
const out = toDiscordMarkdown("### What's new\nbody");
expect(out).toContain("**What's new**");
expect(out).not.toMatch(/^#/m);
});
it("converts tables to bullets, since Discord has none", () => {
const out = toDiscordMarkdown("| Item | Result |\n| --- | --- |\n| #29 | shipped |");
expect(out).toContain("- Item — Result");
expect(out).toContain("- #29 — shipped");
expect(out).not.toContain("|");
});
it("strips <details>, which renders as raw tags", () => {
const out = toDiscordMarkdown("keep\n<details><summary>x</summary>\n- hidden\n</details>");
expect(out).toContain("keep");
expect(out).not.toContain("hidden");
expect(out).not.toContain("<details>");
});
it("strips HTML comments and horizontal rules", () => {
const out = toDiscordMarkdown("a\n\n<!-- note -->\n\n---\n\nb");
expect(out).not.toContain("note");
expect(out).not.toMatch(/^-{3,}$/m);
expect(out).toContain("a");
expect(out).toContain("b");
});
it("leaves bold, italics and bullets alone", () => {
const out = toDiscordMarkdown("- **bold** and *italic* and `code`");
expect(out).toBe("- **bold** and *italic* and `code`");
});
});
describe("buildDescription", () => {
// A three-bullet highlights section over a forty-change release should not
// read as a quiet week.
it("says how much it is not showing", () => {
const out = buildDescription(REAL_SHAPE);
expect(out).toMatch(/plus 2 smaller changes/);
});
it("links the tail to the compare view when there is one", () => {
const out = buildDescription(REAL_SHAPE, { compareUrl: "https://x/compare" });
expect(out).toContain("](https://x/compare)");
});
// The "Also in this release" bullets live INSIDE a <details> block, and the
// file ends with an authoring comment that contains bullets of its own.
// Stripping details before counting reported a real 26-change release as 3.
it("counts the bullets inside <details>, and ignores those in comments", () => {
const body = [
"- highlight",
"<!-- /modal -->",
"<details><summary>Also in this release — 3 smaller changes</summary>",
"- one",
"- two",
"- three",
"</details>",
"<!--",
" - not a change, this is authoring guidance",
" - nor is this",
"-->",
].join("\n");
expect(splitBody(body).tailCount).toBe(3);
expect(buildDescription(body)).toMatch(/plus 3 smaller changes/);
});
it("says nothing about a tail that does not exist", () => {
expect(buildDescription("- only this")).not.toMatch(/smaller change/);
});
it("uses the singular for one", () => {
expect(buildDescription("- a\n<!-- /modal -->\n- b")).toMatch(/1 smaller change\b/);
});
});
describe("clampToLimit", () => {
it("leaves anything under the cap untouched", () => {
expect(clampToLimit("short")).toBe("short");
});
// A hard slice can land inside a link or a bold run, which Discord then
// renders as broken markup — worse than saying less.
it("cuts at a bullet boundary, not mid-markup", () => {
const body = Array.from({ length: 400 }, (_, i) => `- item ${i} with some text`).join("\n");
const out = clampToLimit(body);
expect(out.length).toBeLessThanOrEqual(EMBED_DESCRIPTION_LIMIT);
expect(out.endsWith("…")).toBe(true);
expect(out.split("\n").at(-3)).toMatch(/^- item \d+ with some text$/);
});
});
describe("buildPayload", () => {
it("puts the notes in an embed, because masked links need one", () => {
const p = buildPayload({ version: "v2.12.0", body: REAL_SHAPE, releaseUrl: "https://x/r" });
expect(p.embeds).toHaveLength(1);
expect(p.embeds[0].title).toBe("Wildcard Pipeline v2.12.0");
expect(p.embeds[0].url).toBe("https://x/r");
expect(p.embeds[0].footer.text).toMatch(/ComfyUI Manager/);
});
// Release notes are not written with @everyone in mind, and a webhook will
// happily resolve one that slipped in.
it("never lets the notes mention anyone by accident", () => {
const p = buildPayload({ version: "v1", body: "ping @everyone please" });
expect(p.allowed_mentions.parse).toEqual([]);
expect(p.allowed_mentions.roles).toEqual([]);
});
/**
* An unset secret was always safe. A MALFORMED one was not: any truthy string
* went straight into `<@&…>`, so a stray space produced a literal `<@& >`
* at the top of the most public message the project sends.
*/
describe("role ping", () => {
const content = (roleId) => buildPayload({ version: "v1", body: "x", roleId }).content;
it("stays silent for every flavour of absent", () => {
expect(content(undefined)).toBe("");
expect(content(null)).toBe("");
expect(content("")).toBe("");
expect(content(" ")).toBe("");
});
it("refuses anything that is not a snowflake, rather than guessing", () => {
expect(content("not-an-id")).toBe("");
expect(content("12345")).toBe(""); // too short
expect(content("1234567890123456789012")).toBe(""); // too long
expect(content("123abc456def78901")).toBe("");
});
it("never emits a half-formed mention", () => {
for (const bad of [" ", "not-an-id", "<@&>", "@everyone"]) {
expect(content(bad)).not.toContain("<@&");
}
});
// "Copy ID" and copying the mention text are equally easy mistakes.
it("accepts a pasted mention as well as a bare id", () => {
expect(content("123456789012345678")).toBe("<@&123456789012345678>");
expect(content("<@&123456789012345678>")).toBe("<@&123456789012345678>");
expect(content(" 123456789012345678 ")).toBe("<@&123456789012345678>");
});
it("keeps allowed_mentions in step with the content", () => {
const bad = buildPayload({ version: "v1", body: "x", roleId: "junk" });
expect(bad.allowed_mentions.roles).toEqual([]);
const good = buildPayload({ version: "v1", body: "x", roleId: "123456789012345678" });
expect(good.allowed_mentions.roles).toEqual(["123456789012345678"]);
expect(good.allowed_mentions.parse).toEqual([]);
});
it("normalizeRoleId is the single decision point", () => {
expect(normalizeRoleId("<@&123456789012345678>")).toBe("123456789012345678");
expect(normalizeRoleId("nope")).toBeUndefined();
expect(normalizeRoleId(undefined)).toBeUndefined();
});
});
it("pings only the configured role, and only when configured", () => {
expect(buildPayload({ version: "v1", body: "x" }).content).toBe("");
const p = buildPayload({ version: "v1", body: "x", roleId: "123456789012345678" });
expect(p.content).toBe("<@&123456789012345678>");
expect(p.allowed_mentions.roles).toEqual(["123456789012345678"]);
});
// A webhook posts under whatever name and avatar it was created with —
// Discord's default is a generic name ("Captain Hook") and a placeholder
// image, which is exactly what v2.12.0 went out as.
it("posts under the project's own name and avatar, not the webhook's", () => {
const p = buildPayload({ version: "v1", body: "x" });
expect(p.username).toBe("Wildcard Pipeline");
expect(p.avatar_url).toMatch(/^https:\/\/.+\.png$/);
});
it("uses a raster avatar — Discord will not render an SVG", () => {
expect(buildPayload({ version: "v1", body: "x" }).avatar_url).not.toMatch(/\.svg$/i);
});
it("lets a caller override the identity", () => {
const p = buildPayload({
version: "v1", body: "x", username: "WP Staff", avatarUrl: "https://x/a.png",
});
expect(p.username).toBe("WP Staff");
expect(p.avatar_url).toBe("https://x/a.png");
});
/**
* Two channels, deliberately asymmetric. The staff copy is the review step,
* so it carries the button that publishes it onward; the public copy is the
* announcement itself and must not tell readers how to re-send it.
*/
it("gives the staff copy a publish link, so review and button are one thing", () => {
const p = buildPayload({ version: "v1", body: "x", channel: "staff" });
const field = p.embeds[0].fields[0];
expect(field.value).toContain(DISPATCH_URL);
expect(field.value).toMatch(/channel.*public/i);
});
it("defaults to the staff copy — the safe one to send by accident", () => {
expect(buildPayload({ version: "v1", body: "x" }).embeds[0].fields).toBeTruthy();
});
it("leaves the public copy without the publish link", () => {
const p = buildPayload({ version: "v1", body: "x", channel: "public" });
// `undefined`, not `[]` — an empty field block renders as dead space.
expect(p.embeds[0].fields).toBeUndefined();
});
it("stays inside the embed description cap", () => {
const huge = Array.from({ length: 900 }, (_, i) => `- change ${i}`).join("\n");
const p = buildPayload({ version: "v1", body: huge });
expect(p.embeds[0].description.length).toBeLessThanOrEqual(EMBED_DESCRIPTION_LIMIT);
});
});
describe("buildRollupPayload", () => {
// The case that actually happens: several versions skipped, then one post.
it("labels each version so readers can tell what came from where", () => {
const p = buildRollupPayload({
releases: [
{ version: "v2.12.0", body: "- newest thing" },
{ version: "v2.11.0", body: "- older thing" },
],
sinceVersion: "v2.10.2",
releaseUrl: "https://x/r",
});
expect(p.embeds[0].title).toBe("Wildcard Pipeline v2.12.0 — everything since v2.10.2");
expect(p.embeds[0].description).toContain("**v2.12.0**");
expect(p.embeds[0].description).toContain("**v2.11.0**");
});
it("carries the channel through a roll-up", () => {
const staff = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- x" }], sinceVersion: "v2.11.0", channel: "staff",
});
const pub = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- x" }], sinceVersion: "v2.11.0", channel: "public",
});
expect(staff.embeds[0].fields).toBeTruthy();
expect(pub.embeds[0].fields).toBeUndefined();
});
it("keeps the identity on a roll-up too", () => {
const p = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- x" }],
sinceVersion: "v2.10.2",
});
expect(p.username).toBe("Wildcard Pipeline");
expect(p.avatar_url).toMatch(/\.png$/);
});
it("leads with the newest, which is what people decide to install", () => {
const p = buildRollupPayload({
releases: [
{ version: "v2.12.0", body: "- newest" },
{ version: "v2.11.0", body: "- older" },
],
sinceVersion: "v2.10.2",
});
const d = p.embeds[0].description;
expect(d.indexOf("**v2.12.0**")).toBeLessThan(d.indexOf("**v2.11.0**"));
});
it("drops each release's own tail so a roll-up does not become a changelog", () => {
const p = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- kept\n<!-- /modal -->\n- dropped" }],
sinceVersion: "v2.11.0",
});
expect(p.embeds[0].description).toContain("kept");
expect(p.embeds[0].description).not.toContain("dropped");
});
});
+55
View File
@@ -17,6 +17,7 @@
* - web/ (built manager SPA)
* - public/ (favicons + doc images referenced by the SPA)
* - docs/help/ (per-node help markdown the canvas reads)
* - locales/ (node display names + settings labels ComfyUI reads)
* - README.md
* - LICENSE
*
@@ -59,8 +60,55 @@ const SHIP_PATHS = [
"web",
"public",
"docs/help",
// ComfyUI reads `custom_nodes/<pack>/locales/<lang>/` for node display
// names, descriptions and settings labels (app/custom_node_manager.py).
// The registry archive picks this up automatically because it ships
// everything git-tracked that `.comfyignore` doesn't exclude; this list is
// hand-maintained, so it did not. The two artifacts silently disagreed —
// see DEV_ONLY below for the guard that now stops that recurring.
"locales",
];
/**
* Top-level tracked entries that deliberately do NOT ship.
*
* Exists to make the ship list total: every top-level path in git is either
* shipped or listed here, and `assertShipListIsTotal` fails the release if a
* new one appears in neither. Without it, adding a runtime directory and
* forgetting this file produces a zip that installs and silently misbehaves —
* which is exactly how `locales/` went missing.
*/
const DEV_ONLY = new Set([
".comfyignore", ".gitattributes", ".github", ".gitignore", ".husky", ".npmrc",
".releaserc.js", "CHANGELOG.md", "CLAUDE.md", "config", "conftest.py",
"eslint.config.js", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml",
"scripts", "src", "tests", "tsconfig.json", "tsconfig.node.json",
"vite.config.mts", "vitest.config.ts",
]);
function assertShipListIsTotal() {
const ls = spawnSync("git", ["ls-files"], { cwd: root, encoding: "utf8" });
if (ls.status !== 0) {
console.warn("pack-release: `git ls-files` unavailable — skipping ship-list completeness check");
return;
}
// A ship path may name a subtree (`docs/help`); its top-level segment is
// what makes the parent directory "accounted for".
const covered = new Set(SHIP_PATHS.map((p) => p.split("/")[0]));
const tracked = new Set(
ls.stdout.split("\n").map((line) => line.trim().split("/")[0]).filter(Boolean),
);
const unaccounted = [...tracked].filter((e) => !covered.has(e) && !DEV_ONLY.has(e)).sort();
if (unaccounted.length > 0) {
console.error("pack-release: top-level paths in git that are neither shipped nor dev-only:");
for (const p of unaccounted) console.error(` - ${p}`);
console.error("Add each to SHIP_PATHS (ships to users) or DEV_ONLY (stays in the repo).");
process.exit(1);
}
}
assertShipListIsTotal();
// Sanity: every path must exist before zipping. If `js/` or `web/`
// is missing the release would ship a broken bundle — catch here so the
// release workflow fails fast rather than uploading a half-baked zip.
@@ -93,6 +141,13 @@ const args = [
];
const result = spawnSync("zip", args, { stdio: "inherit", cwd: root });
if (result.error?.code === "ENOENT") {
// Windows has no `zip` by default, so a maintainer running this locally
// otherwise gets a bare "exited with status null".
console.error("pack-release: the `zip` CLI was not found on PATH.");
console.error("The release workflow runs on ubuntu-latest where it is always present.");
process.exit(1);
}
if (result.status !== 0) {
console.error(`pack-release: zip exited with status ${result.status}`);
process.exit(result.status ?? 1);
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env node
// Record exactly which files a clean build produced, so the plugin can delete
// everything else on the next ComfyUI start.
//
// WHY THIS EXISTS. Our chunks are content-hashed, so every release emits new
// filenames. ComfyUI Manager updates by extracting the new package OVER the
// installed folder — it never removes files the new version dropped. The two
// behaviours are individually reasonable and together they leak: measured on a
// real 2.13.1 install (2026-08-06), `js/` held 237 chunks where 58 were live
// and `web/` held 1,256 files where 136 were, for 54.6 MB total.
//
// It goes unnoticed in development because `emptyOutDir` wipes the output
// directory on every local build. Only installs that UPDATE accumulate.
//
// The manifest lists paths relative to the directory it sits in. Written last,
// after the copy steps, so it captures docs and fonts as well as chunks.
//
// Usage: node scripts/write-asset-manifest.mjs <dir>
import { readdirSync, statSync, writeFileSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("..", import.meta.url));
const target = process.argv[2];
if (!target) {
console.error("usage: write-asset-manifest.mjs <dir> (e.g. js, web)");
process.exit(1);
}
const dir = join(ROOT, target);
const MANIFEST = ".wp-assets.json";
function walk(current) {
const out = [];
for (const name of readdirSync(current)) {
const full = join(current, name);
if (statSync(full).isDirectory()) out.push(...walk(full));
else out.push(full);
}
return out;
}
try {
const files = walk(dir)
// Always use forward slashes: the manifest is written on whatever machine
// builds the release and read on the user's, which may not be the same OS.
.map((p) => relative(dir, p).split(sep).join("/"))
.filter((p) => p !== MANIFEST)
.sort();
writeFileSync(
join(dir, MANIFEST),
`${JSON.stringify({ files }, null, 0)}\n`,
"utf8",
);
console.log(` ${target}/${MANIFEST} — ${files.length} files recorded`);
} catch (err) {
console.error("[write-asset-manifest] failed:", err);
process.exit(1);
}
Binary file not shown.
+56
View File
@@ -0,0 +1,56 @@
/* Webfonts for the canvas extension — JetBrains Mono, latin subset,
* variable axis 100-900.
*
* NO INTER HERE, deliberately. ComfyUI's own frontend serves an Inter that is
* identical to the one we used to ship — same family name, same
* `font-weight: 100 900` variable axis, same latin woff2, same
* `font-display: swap` — and it declares it in its MAIN stylesheet, so it is
* already loaded on every page a canvas widget can appear on. We were shipping
* and downloading a 48 KB duplicate.
*
* The coupling this creates is on the FAMILY NAME, not on their URL: our
* `--wp-font-sans` stack names `Inter` and the browser resolves it against
* whatever `Inter` face is loaded, so their file being renamed, re-subset or
* re-hashed is invisible to us. Their copy also lives in `public/fonts/`,
* which Vite serves un-hashed precisely so the URL stays stable — hashed
* assets go to `assets/`.
*
* Evidence for the bet, since "rely on a host asset" deserves some: added
* 2025-09-16 in Comfy-Org/ComfyUI_frontend 4ff18fd ("Add Inter font used by
* Figma designs", #5535) and untouched since — one commit in its whole
* history. It is the typeface of their entire UI, so removing it means
* restyling their product, not dropping an optional extra.
*
* And if they do remove it, nothing breaks loudly: the stack falls through to
* `-apple-system, Segoe UI, Roboto, sans-serif`, which is the behaviour
* `font-display: swap` already shows during every cold load.
*
* JetBrains Mono stays. ComfyUI ships no monospace face.
*
* This file is NOT processed by Vite. `scripts/copy-fonts.mjs` copies this
* directory verbatim into `js/assets/fonts/`, and `src/extension/fonts.ts`
* injects a <link> to it at runtime.
*
* Why not an import: Vite library mode ALWAYS base64-inlines assets and
* ignores `build.assetsInlineLimit` entirely, so any woff2 reachable from a
* module graph becomes ~33% larger and lands inside a JS chunk — parsed as
* JavaScript, re-downloaded whenever that chunk's hash changes, and counted
* against the bundle-size gate. Keeping the fonts outside the graph is the
* only way to ship them as fonts.
*
* The `src` URLs below are relative to THIS FILE, so the browser resolves
* them against the stylesheet's own location. No path arithmetic in JS.
*
* font-display: swap renders the system stack first and re-paints when the
* woff2 lands, so text is never invisible during the fetch.
*/
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src:
url("./jetbrains-mono-latin.woff2") format("woff2-variations"),
url("./jetbrains-mono-latin.woff2") format("woff2");
}
+62
View File
@@ -0,0 +1,62 @@
// Everything `main.ts` needs before it can call `registerExtension`, gathered
// behind ONE dynamic import so it ships as one chunk.
//
// WHY ONE CHUNK. `main.ts` used to `await Promise.all([...])` nineteen separate
// `import()`s. Each became its own chunk, and every module two of them shared
// (vue, `_shared`, `graph`, `reactive`, `graph-events`, …) became a chunk of
// its own too, so an empty canvas fetched 43 files before the extension could
// register. All of them were awaited up front anyway: nothing was actually
// deferred, it was just split. Importing them from here lets Rollup put them in
// a single chunk, and the lazy chunks (the widget SFCs, modals) import from it
// because it is guaranteed to be loaded before they are.
//
// WHY IT STILL HAS TO BE AWAITED. `getCustomWidgets` factories must return
// synchronously, so the widget glue has to be loaded before the first node is
// built. ComfyUI does accept a Promise from `getCustomWidgets`, but it does not
// wait for it (`extensionService.registerExtension` fires it and moves on,
// marked for deprecation), so a workflow restored at startup could build its
// nodes first. And `init`, the earliest hook ComfyUI does await, runs right
// after extension loading, so moving the await there would buy nothing.
//
// Keep this list to what registration or node creation needs. Anything only a
// user action reaches (a modal, the toast stack, the subgraph badge) belongs in
// its own `import()` so it stays off the startup path. The widget SFCs are
// already lazy through `defineAsyncComponent` in each `widgets/*.ts`.
// Webfonts. The module only appends a <link> for the @font-face rules and
// `font-display: swap` renders text in the system stack until they land, so
// nothing here waits on a font. It resolves the stylesheet against its own
// chunk's URL, which is `assets/` whether it ships alone or inside this chunk.
import "./extension/fonts";
export * as ctxMod from "./widgets/context";
export * as dbgMod from "./widgets/debug";
export * as asmMod from "./widgets/assembler";
export * as tmplMod from "./widgets/templateEditor";
export * as injMod from "./widgets/injector";
export * as cleanerMod from "./widgets/cleaner";
export * as varPickerMod from "./widgets/var_picker";
export * as ctxLoopMod from "./widgets/context_loop";
export * as seedListMod from "./widgets/seed_list";
export * as modelInfoMod from "./widgets/model_info";
export * as imageFilterWidgetMod from "./widgets/image_filter";
export * as imageFilterMod from "./extension/image-filter";
export * as graphEventsMod from "./extension/graph-events";
export * as graphMod from "./extension/graph";
export * as assemblerVarsMod from "./extension/assembler-vars";
export * as toastStoreMod from "./components/shared/toast-store";
export * as settingsMod from "./extension/settings";
export * as aboutMod from "./extension/about-badges";
export * as topbarMod from "./extension/topbar";
export * as playgroundStoreMod from "./components/settings/playground-store";
export { installClipboardShield } from "./widgets/clipboard-shield";
export { dimWidgetsWhileSkipped } from "./extension/reactive";
export { createApp, watch } from "vue";
// Off the startup path, but imported from here rather than from `main.ts`: a
// chunk loaded from this one can reuse everything this one already holds (vue,
// the stores, `reactive`), whereas one loaded from `main.ts` makes Rollup split
// those shared modules back out into files of their own.
export const loadToast = () => import("./components/shared/Toast.vue");
export const loadPlayground = () => import("./components/settings/DisplayPlaygroundModal.vue");
export const loadSubgraphBadge = () => import("./extension/subgraph-badge");
export const loadImageFilterPicker = () => import("./components/image-filter/PickerHost.vue");
@@ -452,3 +452,78 @@ describe("AssemblerHelper var-color rendering", () => {
expect(wrapper.find('[data-test="asm-save-template"]').attributes("disabled")).toBeDefined();
});
});
describe("AssemblerHelper $var.AXIS preview (tag axes)", () => {
const base = {
upstreamVars: ["outfit", "shoes"],
templateVars: ["outfit", "shoes"],
resolvedMap: { outfit: "robe", shoes: "red stilettos" },
varAxes: { outfit: { SHOES: "sandals" } },
};
const preview = (props: Record<string, unknown>) =>
mount(AssemblerHelper, { props: { ...base, ...props } as never })
.find('[data-test="asm-preview"]').text();
it("reads the tag the preview run rolled, not the axis's first tag", () => {
expect(preview({
template: "$outfit.SHOES | $outfit",
resolvedMap: { outfit: "summer dress", shoes: "red stilettos" },
rolledAxes: { outfit: { SHOES: "heels" } },
})).toBe("heels | summer dress");
});
it("renders empty when the picked option carries no tag on the axis", () => {
// The reported preview: "sandals | robe" while the prompt said "| robe".
expect(preview({ template: "$outfit.SHOES | $outfit", rolledAxes: { outfit: {} } }))
.toBe("| robe");
});
it("guesses the first tag only until the preview run answers", () => {
expect(preview({ template: "$outfit.SHOES" })).toBe("sandals");
});
it("keeps an undeclared axis raw so the gap stays visible", () => {
expect(preview({ template: "$outfit.BELTS", rolledAxes: { outfit: {} } }))
.toBe("$outfit.BELTS");
});
it("indexes a multi-pick positionally in either accessor order", () => {
const rolledAxes = { outfit: [{}, { SHOES: "boots" }] };
expect(preview({ template: "[$outfit.0.SHOES] [$outfit.1.SHOES] [$outfit.SHOES.1]", rolledAxes }))
.toBe("[] [boots] [boots]");
expect(preview({ template: "$outfit.SHOES", rolledAxes })).toBe("boots");
});
});
describe("AssemblerHelper negative preview", () => {
const base = {
upstreamVars: ["hair", "mood"],
resolvedMap: { hair: "red hair", mood: "happy" },
negatives: {
hair: [{ text: "blonde, lowres", pick: null }],
mood: [{ text: "sad", pick: null }],
},
};
it("builds the line from the variables the prompt uses, at $negatives", () => {
const wrapper = mount(AssemblerHelper, {
props: { ...base, template: "$hair", negativeTemplate: "lowres, not $mood, $negatives" },
});
const line = wrapper.find('[data-test="asm-neg-preview"]');
// $mood is only in the NEGATIVE template: its value, never its negatives.
expect(line.text()).toBe("lowres, not happy, blonde");
expect(wrapper.find('[data-test="asm-neg-count"]').text()).toBe("1 from variables");
expect(line.find(`.${varColorClass("hair")}`).text()).toBe("blonde");
});
it("empty negative template is just the words; hidden when nothing to show", () => {
const shown = mount(AssemblerHelper, {
props: { ...base, template: "$hair, $mood", negativeTemplate: "" },
});
expect(shown.find('[data-test="asm-neg-preview"]').text()).toBe("blonde, lowres, sad");
const hidden = mount(AssemblerHelper, {
props: { ...base, template: "a portrait", negativeTemplate: "" },
});
expect(hidden.find('[data-test="asm-neg-preview"]').exists()).toBe(false);
});
});
+143 -40
View File
@@ -1,9 +1,17 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { varColorClass } from "../shared/var-color";
import { modelSyntaxHtml } from "../../widgets/richTokenize";
import { buildNegativePreview, type NegativesTable } from "../../extension/negatives";
import ContextMenu, { type ContextMenuItem } from "../shared/ContextMenu.vue";
import { kindIcon, type WpKind } from "../shared/kind-icons";
import { applyVarAccessor, type ResolvedValue } from "../../widgets/richTokenize";
import {
applyVarAccessor,
readRolledAxis,
varAccessorParts,
type ResolvedValue,
type RolledAxes,
} from "../../widgets/richTokenize";
const props = defineProps<{
/** Flat array of upstream variable names. Primary prop shape. */
@@ -17,6 +25,16 @@ const props = defineProps<{
* precedence over `resolved`.
*/
resolvedMap?: Record<string, ResolvedValue>;
/** `{ varName: { AXIS: firstTag } }`. A `$var.AXIS` read resolves against
* this, never against `resolvedMap` — the axis names a tag menu, not the
* variable's text. */
varAxes?: Record<string, Record<string, string>>;
/** What the preview run actually rolled, keyed by binding (the preview
* endpoint's `axes`). Preferred over `varAxes` for any axis the chain
* declares, because `varAxes` only knows the axis's first tag — it printed
* "sandals" beside an option with no shoes and beside a pick whose
* constrained target had rolled heels. */
rolledAxes?: Record<string, RolledAxes>;
/**
* Pre-resolved string — template with $var replaced by resolved values.
* Legacy fallback when `resolvedMap` not provided. Suffers from
@@ -25,6 +43,11 @@ const props = defineProps<{
*/
resolved?: string;
template: string;
/** Send-to-negative: the node's `negative_template` value. */
negativeTemplate?: string;
/** Each variable's negatives (`{binding: [{text, pick}]}`) from the
* preview run, injector rows already folded in. */
negatives?: NegativesTable;
onInsert?: (token: string) => void;
/** Strip every occurrence of `$varname` from the template. Wired
* to the UNRESOLVED chips so users can one-click drop a name
@@ -85,7 +108,12 @@ const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
// SP2a: capture an optional `.K` list accessor (group 2) so `$mood.0` consumes
// the whole token — `m[0].length` then advances past `.K` (no stranded ".0"
// literal) and group 2 drives applyVarAccessor in the substitution below.
const TEMPLATE_VAR_RE = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?/g;
// Groups: 1=name · 2=index · 3=axis. The accessor has to be part of the match
// or the preview prints the resolved value and strands ".SHOES" after it.
// Both accessor orders the engine accepts (`$o.0.SHOES`, `$o.SHOES.0`); the
// match is re-split by `varAccessorParts`, the one canonical grammar.
const TEMPLATE_VAR_RE =
/(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(?:\d+(?:\.[A-Za-z_][A-Za-z0-9_]*)?|[A-Za-z_][A-Za-z0-9_]*(?:\.\d+)?))?/g;
/** Variable names referenced in the template. */
const templateVarsInternal = computed(() => {
@@ -134,6 +162,22 @@ watch(
interface PreviewToken { kind: "literal" | "var"; text: string; varName?: string }
/** An axis read resolves to a TAG. Falling through to the value is what
* rendered "a white t-shirt and denim skirt.SHOES". An axis the chain does not
* declare stays raw so the gap is visible rather than silently dropped; a
* declared one reads what the preview run rolled (empty when the picked
* option carries no tag on it, exactly as the prompt will), and only guesses
* the axis's first tag until that run has answered. */
function axisText(name: string, axis: string, index: number | undefined, raw: string): string {
const guess = props.varAxes?.[name]?.[axis];
if (guess === undefined) return raw;
const rolled = props.rolledAxes;
if (rolled && Object.prototype.hasOwnProperty.call(rolled, name)) {
return readRolledAxis(rolled[name], axis, index);
}
return index != null && index > 0 ? "" : guess;
}
const previewTokens = computed<PreviewToken[]>(() => {
if (!props.template) return [];
@@ -145,17 +189,14 @@ const previewTokens = computed<PreviewToken[]>(() => {
for (const m of props.template.matchAll(TEMPLATE_VAR_RE)) {
const idx = m.index ?? 0;
if (idx > last) tokens.push({ kind: "literal", text: props.template.slice(last, idx) });
const name = m[1];
const idxStr = m[2];
const index = idxStr != null ? parseInt(idxStr, 10) : undefined;
const raw = m[0];
const { base: name, index, axis } = varAccessorParts(raw);
const has = Object.prototype.hasOwnProperty.call(props.resolvedMap, name);
// Resolved → join (bare $name) or index (.K) via the shared accessor;
// unresolved → keep the raw `$name.K` so the user sees what's missing.
tokens.push({
kind: "var",
text: has
? applyVarAccessor(props.resolvedMap[name], index)
: `$${name}${idxStr != null ? "." + idxStr : ""}`,
text: axis
? axisText(name, axis, index, raw)
: (has ? applyVarAccessor(props.resolvedMap[name], index) : raw),
varName: name,
});
last = idx + m[0].length;
@@ -221,6 +262,41 @@ const previewTokens = computed<PreviewToken[]>(() => {
return [];
});
// ---------------------------------------------------------------------------
// Negative preview (send-to-negative)
// ---------------------------------------------------------------------------
/** Render text's `$vars` the way the prompt preview does (value, index,
* axis); a variable with no value renders nothing, as in the engine. */
function renderVarsRaw(text: string): string {
const map = props.resolvedMap ?? {};
return text.replace(TEMPLATE_VAR_RE, (raw) => {
const { base, index, axis } = varAccessorParts(raw);
if (axis) {
const out = axisText(base, axis, index, raw);
return out === raw ? "" : out;
}
return Object.prototype.hasOwnProperty.call(map, base) ? applyVarAccessor(map[base], index) : "";
});
}
/** What the node's `negative` output will be, built locally from the
* negatives of the variables the PROMPT template uses. */
const negPreview = computed(() =>
buildNegativePreview({
template: props.template,
negativeTemplate: props.negativeTemplate ?? "",
resolved: props.resolvedMap ?? {},
negatives: props.negatives ?? {},
resolveRaw: renderVarsRaw,
}),
);
/** Only worth a row when there is something to show. */
const showNegative = computed(
() => negPreview.value.text !== "" || (props.negativeTemplate ?? "").trim() !== "",
);
const isResolved = computed(() => {
if (props.resolvedMap !== undefined) return Object.keys(props.resolvedMap).length > 0;
return Boolean(props.resolved);
@@ -396,7 +472,6 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
@click="onLoadTemplate"
>
<i class="pi pi-folder-open" aria-hidden="true" />
<span>Load</span>
</button>
<button
v-if="onSaveTemplate"
@@ -409,7 +484,6 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
@click="onSaveTemplate"
>
<i class="pi pi-save" aria-hidden="true" />
<span>Save</span>
</button>
<button
v-if="onClearTemplate"
@@ -422,7 +496,6 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
@click="onClearTemplate"
>
<i class="pi pi-trash" aria-hidden="true" />
<span>Clear template</span>
</button>
</div>
<span class="wp-asm-section-stat">
@@ -490,18 +563,47 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
</div>
<div v-else key="tokens" class="wp-asm-preview__tokens">
<template v-for="(tok, i) in previewTokens" :key="i">
<span v-if="tok.kind === 'literal'" class="literal">{{ tok.text }}</span>
<!-- Literal preview text still carries `<lora:…>` and
`embedding:…`, which resolve to themselves rather than to a
variable — so without this they were the only place the two
syntaxes appeared uncoloured. Same function the editor uses,
so the two cannot drift; it escapes everything it emits. -->
<span
v-if="tok.kind === 'literal'"
class="literal"
v-html="modelSyntaxHtml(tok.text)"
></span>
<span v-else :class="['res', varColorClass(tok.varName ?? '')]">{{ tok.text }}</span>
</template>
</div>
</Transition>
</div>
<!-- hint -->
<div class="wp-asm-hint">
<span>click → insert <kbd>$var</kbd> · <kbd>Ctrl</kbd>+click → remove · right-click → more</span>
<span style="margin-left: auto;">click missing → remove</span>
</div>
<template v-if="showNegative">
<div class="wp-asm-section">
<span>negative preview</span>
<span
v-if="negPreview.fromVars.length"
class="wp-asm-section-stat wp-asm-section-stat--neg"
data-test="asm-neg-count"
>{{ negPreview.fromVars.length }} from variables</span>
</div>
<div class="wp-asm-preview wp-asm-preview--neg" data-test="asm-neg-preview">
<span v-if="!negPreview.text" class="wp-asm-preview__ghost">
No negatives: none of the variables the prompt uses carries one.
</span>
<template v-for="(tag, i) in negPreview.tags" v-else :key="i">
<span v-if="i > 0" class="literal">, </span>
<span
v-if="tag.varName"
:class="['res', varColorClass(tag.varName)]"
:title="`from $${tag.varName}`"
>{{ tag.text }}</span>
<span v-else class="literal">{{ tag.text }}</span>
</template>
</div>
</template>
</div>
</Transition>
@@ -550,6 +652,12 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
}
.wp-asm-section-stat--warn { color: var(--wp-warn); }
.wp-asm-section-stat.is-ok { color: var(--wp-green); }
.wp-asm-section-stat--neg {
padding: 2px 6px;
border-radius: 3px;
color: var(--wp-danger);
background: color-mix(in srgb, var(--wp-danger) 14%, transparent);
}
/* Toolbar buttons (Load / Save / Clear) — sit in the variables
* section header, centered between the label and the upstream-count
@@ -588,11 +696,15 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
gap: 6px;
justify-content: center;
}
/* Icon-only. Each button already carried a `title` and an `aria-label`, so the
visible word was the only thing dropped — and three words across the row
were most of its width. Square-ish padding keeps the tap target honest. */
.wp-asm-toolbtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 3px 10px;
padding: 4px 7px;
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px solid var(--wp-border);
border-radius: 3px;
@@ -665,8 +777,11 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
.wp-asm-vars {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px;
/* On the project's own spacing scale rather than the bare 4/6 they were.
At 4px the chips ran together into one block of texture — the strip read
as a single control instead of a row of separate variables. */
gap: var(--wp-space-3);
padding: var(--wp-space-4);
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
@@ -729,6 +844,12 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
}
.wp-asm-preview .literal { color: var(--wp-text); }
.wp-asm-preview .res { font-weight: 600; }
/* Negative preview: same frame, negative accent. */
.wp-asm-preview--neg {
border-color: color-mix(in srgb, var(--wp-danger) 35%, var(--wp-border));
}
.wp-asm-preview--neg .wp-asm-preview__ghost { font-style: italic; }
/* Tokens wrapper is a block `<div>` (not inline, not display:contents)
* so the Vue Transition can apply transform/opacity to it. Inline
* elements ignore transform per CSS spec; `display: contents` makes
@@ -774,22 +895,4 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
}
.wp-asm-preview__ghost-text { display: inline; }
.wp-asm-hint {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font: 11px/1.3 var(--wp-font-sans);
color: var(--wp-text-dim);
}
.wp-asm-hint kbd {
background: var(--wp-bg-1);
border: 1px solid var(--wp-border-soft);
border-bottom-width: 2px;
border-radius: 2px;
font: 10px/1 var(--wp-font-mono);
padding: 1px 4px;
color: var(--wp-text-muted);
}
</style>
@@ -65,6 +65,23 @@ describe("LoadTemplateModal", () => {
expect(w.find('[data-test="load-tpl-filter-trigger"]').exists()).toBe(true);
});
it("shows a saved negative under the template", async () => {
const { api } = await import("../../manager/api/client");
(api.templates.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
items: [{
id: "n1", name: "neg", description: "", category_id: null, tags: [],
is_favorite: false, template_string: "$x", negative_template: "lowres, $negatives",
created_at: "", updated_at: "",
}],
total: 1,
});
const w = mount(LoadTemplateModal, { props: { open: true }, ...stubs });
await flushPromises();
expect(w.find('[data-test="load-tpl-row-neg"]').text()).toBe("neg: lowres, $negatives");
await w.find('[data-test="load-tpl-row"]').trigger("click");
expect(w.emitted("pick")?.[0]?.[0]).toMatchObject({ negative_template: "lowres, $negatives" });
});
it("shows empty state when none", async () => {
const { api } = await import("../../manager/api/client");
(api.templates.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ items: [], total: 0 });
@@ -253,6 +253,12 @@ function pick(row: TemplateRow) {
<i v-if="row.is_favorite" class="pi pi-star-fill wp-ltm__row-fav" aria-hidden="true" />
</span>
<span class="wp-ltm__row-preview" :title="row.template_string">{{ row.template_string || "(empty)" }}</span>
<span
v-if="row.negative_template"
class="wp-ltm__row-preview wp-ltm__row-preview--neg"
data-test="load-tpl-row-neg"
:title="row.negative_template"
>neg: {{ row.negative_template }}</span>
</span>
<span v-if="row.category_id && categoryNameById.get(row.category_id)" class="wp-ltm__row-cat">
{{ categoryNameById.get(row.category_id) }}
@@ -505,6 +511,7 @@ function pick(row: TemplateRow) {
color: var(--wp-text-muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.wp-ltm__row-preview--neg { color: color-mix(in srgb, var(--wp-danger) 75%, var(--wp-text-muted)); }
.wp-ltm__row-cat {
font-size: 10px;
color: var(--wp-text-dim);
@@ -0,0 +1,139 @@
<script setup lang="ts">
/**
* The Assembler's `negative_template` box: the same editor as the prompt
* template behind a collapsible header, with the negative (danger) accent.
*
* Heights are fixed on purpose (header 30px, open box 100px, both on the
* LiteGraph 10px grid) so the mount glue can report an exact content height
* and the node gives every spare pixel to the prompt editor above instead of
* leaving a dead band here.
*/
import { computed, defineAsyncComponent } from "vue";
import type { VarProducer } from "../../extension/graph";
const RichTextInput = defineAsyncComponent(() => import("../../manager/components/RichTextInput.vue"));
const props = defineProps<{
modelValue: string;
collapsed: boolean;
/** Driven by a STRING link: read-only, like the prompt template. */
linked: boolean;
varSuggestions: string[];
varProducers: Map<string, VarProducer>;
}>();
const emit = defineEmits<{
(e: "update:modelValue", v: string): void;
(e: "toggle"): void;
}>();
/** One-line summary shown in the collapsed header. */
const summary = computed(() => {
if (props.linked) return "driven by the connected input";
const t = props.modelValue.trim().replace(/\s+/g, " ");
return t || "empty: just the variables' negatives";
});
</script>
<template>
<div
class="wp-negbox"
:class="{ 'wp-negbox--collapsed': collapsed }"
data-test="neg-template-box"
>
<button
type="button"
class="wp-negbox__head"
:aria-expanded="!collapsed"
aria-controls="wp-negbox-body"
data-test="neg-template-toggle"
:title="collapsed ? 'Show the negative template' : 'Hide the negative template'"
@click="emit('toggle')"
>
<i
:class="['pi', collapsed ? 'pi-chevron-right' : 'pi-chevron-down', 'wp-negbox__chev']"
aria-hidden="true"
/>
<span class="wp-negbox__label">negative</span>
<span v-if="collapsed" class="wp-negbox__summary" data-test="neg-template-summary">{{ summary }}</span>
<span v-else-if="linked" class="wp-negbox__summary">driven by the connected input</span>
</button>
<div v-if="!collapsed" class="wp-negbox__body">
<RichTextInput
:model-value="modelValue"
surface="assembler"
multiline
:rows="2"
fill
:placeholder="linked
? 'Driven by the connected input — disconnect it to edit here.'
: '$negatives'"
:disabled="linked"
:var-suggestions="varSuggestions"
:var-producers="varProducers"
graph-aware
aria-label="Negative template"
@update:model-value="(v: string) => emit('update:modelValue', v)"
/>
</div>
</div>
</template>
<style scoped>
/* Tokens (`--wp-*`) resolve from the theme the Assembler helper already
loads; not @imported here so this chunk stays small (bundle gate). */
.wp-negbox {
display: flex;
flex-direction: column;
height: 100px;
box-sizing: border-box;
padding: 0 6px;
font-family: var(--wp-font-sans);
color: var(--wp-text);
}
.wp-negbox--collapsed { height: 30px; }
.wp-negbox__head {
display: flex;
align-items: center;
gap: 6px;
height: 26px;
flex: 0 0 26px;
min-width: 0;
padding: 0 2px;
border: 0;
background: transparent;
color: var(--wp-danger);
font: 500 11px/1 var(--wp-font-sans);
text-transform: lowercase;
cursor: pointer;
text-align: left;
}
.wp-negbox__head:focus-visible {
outline: 1px solid var(--wp-danger);
outline-offset: 1px;
}
.wp-negbox__chev { font-size: 9px; }
.wp-negbox__summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--wp-text-dim);
font: 11px/1 var(--wp-font-mono);
text-transform: none;
}
.wp-negbox__body {
flex: 1 1 0%;
min-height: 0;
padding-bottom: 4px;
}
/* Negative accent on the editor frame. The editor is a child component, so
reach its root through :deep(). */
.wp-negbox__body :deep(.wp-rt) {
border-color: color-mix(in srgb, var(--wp-danger) 45%, var(--wp-border));
background: color-mix(in srgb, var(--wp-danger) 6%, var(--wp-bg-2, var(--wp-bg)));
}
.wp-negbox__body :deep(.wp-rt.wp-rt--focused) { border-color: var(--wp-danger); }
.wp-negbox__body :deep(.wp-rt__host--multi) { min-height: 0; }
</style>
@@ -93,4 +93,34 @@ describe("SaveTemplateModal", () => {
expect(api.templates.create).toHaveBeenCalled();
expect(api.templates.update).not.toHaveBeenCalled();
});
it("stores both boxes; an empty negative box saves as an empty string", async () => {
const { api } = await import("../../manager/api/client");
const create = api.templates.create as ReturnType<typeof vi.fn>;
create.mockClear();
const w = mount(SaveTemplateModal, {
props: { open: true, templateString: "$a", negativeTemplate: "lowres, $negatives" },
...stubs,
});
await flushPromises();
expect(w.find('[data-test="save-tpl-neg-preview"]').text()).toBe("lowres, $negatives");
const input = w.find<HTMLInputElement>('[data-test="save-tpl-name"]');
input.element.value = "both";
await input.trigger("input");
await w.find('[data-test="save-tpl-submit"]').trigger("click");
await flushPromises();
expect(create).toHaveBeenLastCalledWith(expect.objectContaining({
template_string: "$a", negative_template: "lowres, $negatives",
}));
const w2 = mount(SaveTemplateModal, { props: { open: true, templateString: "$a" }, ...stubs });
await flushPromises();
expect(w2.find('[data-test="save-tpl-neg-preview"]').exists()).toBe(false);
const input2 = w2.find<HTMLInputElement>('[data-test="save-tpl-name"]');
input2.element.value = "blank";
await input2.trigger("input");
await w2.find('[data-test="save-tpl-submit"]').trigger("click");
await flushPromises();
expect(create).toHaveBeenLastCalledWith(expect.objectContaining({ negative_template: "" }));
});
});
+18 -1
View File
@@ -7,6 +7,10 @@ import { pushToast } from "../shared/toast-store";
const props = defineProps<{
open: boolean;
templateString: string;
/** The Assembler's negative box. Saved alongside the prompt template
* (an empty box saves as "", which loading applies; only templates saved
* before negatives existed carry none). */
negativeTemplate?: string;
/** Library identity of the template this assembler loaded, if any.
* Pre-fills the name and makes "Update existing" target that exact
* row even before the same-name collision map resolves. */
@@ -67,6 +71,7 @@ async function submit(mode: "update" | "new") {
const body = {
name: name.value.trim(),
template_string: props.templateString,
negative_template: props.negativeTemplate ?? "",
description: description.value,
category_id: categoryId.value,
tags: tagsParsed.value,
@@ -157,6 +162,14 @@ async function submit(mode: "update" | "new") {
<pre class="wp-stm-preview" data-test="save-tpl-preview">{{ templateString || "(empty)" }}</pre>
</div>
<div v-if="negativeTemplate" class="wp-stm-field">
<label class="wp-stm-field__label wp-stm-field__label--neg">Negative template</label>
<pre
class="wp-stm-preview wp-stm-preview--neg"
data-test="save-tpl-neg-preview"
>{{ negativeTemplate }}</pre>
</div>
<p v-if="errorMsg" class="wp-stm-error" data-test="save-tpl-error">{{ errorMsg }}</p>
</div>
@@ -260,7 +273,7 @@ async function submit(mode: "update" | "new") {
font-family: inherit;
}
.wp-stm-input:focus { outline: 1px solid var(--wp-accent-500, var(--wp-accent)); }
.wp-stm-input--multi { resize: vertical; min-height: 46px; }
.wp-stm-input--multi { resize: vertical; overscroll-behavior: contain; min-height: 46px; }
.wp-stm-select { cursor: pointer; }
.wp-stm-preview {
margin: 0;
@@ -276,6 +289,10 @@ async function submit(mode: "update" | "new") {
max-height: 110px;
color: var(--wp-text-muted, #a1a1ad);
}
.wp-stm-preview--neg {
border-color: color-mix(in srgb, var(--wp-danger) 40%, var(--wp-border, #2a2a3a));
}
.wp-stm-field__label--neg { color: var(--wp-danger); }
.wp-stm-note {
display: flex;
align-items: flex-start;
+1 -1
View File
@@ -174,7 +174,7 @@ function onCancel(): void {
padding: 8px 10px;
font-family: var(--wp-font-mono, ui-monospace, "JetBrains Mono", monospace);
font-size: 12px;
resize: vertical;
resize: vertical; overscroll-behavior: contain;
box-sizing: border-box;
outline: none;
}
+105 -1
View File
@@ -21,7 +21,7 @@ describe("CleanerWidget", () => {
expect(w.find('[data-test="cleaner-intensity-gentle"]').exists()).toBe(true);
expect(w.find('[data-test="cleaner-intensity-balanced"]').exists()).toBe(true);
expect(w.find('[data-test="cleaner-intensity-aggressive"]').exists()).toBe(true);
expect(w.findAll('[data-test^="cleaner-rule-"]:not([data-test$="-stat"])')).toHaveLength(5);
expect(w.findAll('[data-test^="cleaner-rule-"]:not([data-test$="-stat"])')).toHaveLength(8);
});
it("CUSTOM badge marked visible when modified", () => {
@@ -64,6 +64,29 @@ describe("CleanerWidget", () => {
expect(w.find('[data-test="cleaner-rule-dedupe_exact-stat"]').text()).toContain("1");
});
it("shows counts for the bracket and LoRA rules", () => {
const w = mount(CleanerWidget, {
props: makeProps({ intensity: "aggressive" }, {
lastRunReport: {
empty_groups: { removed: 2 },
merge_weights: { merged: 1 },
lora_spacing: { tidied: 4 },
},
}),
});
expect(w.find('[data-test="cleaner-rule-empty_groups-stat"]').text()).toBe("2");
expect(w.find('[data-test="cleaner-rule-merge_weights-stat"]').text()).toBe("1");
expect(w.find('[data-test="cleaner-rule-lora_spacing-stat"]').text()).toBe("4");
});
it("merge weights is off on balanced and on for aggressive", () => {
const balanced = mount(CleanerWidget, { props: makeProps() });
expect(balanced.find('[data-test="cleaner-rule-merge_weights"]').classes()).not.toContain("is-on");
expect(balanced.find('[data-test="cleaner-rule-empty_groups"]').classes()).toContain("is-on");
const aggressive = mount(CleanerWidget, { props: makeProps({ intensity: "aggressive" }) });
expect(aggressive.find('[data-test="cleaner-rule-merge_weights"]').classes()).toContain("is-on");
});
it("blocklist button shows entry count when populated", () => {
const w = mount(CleanerWidget, {
props: makeProps({ blocklist: { kind: "list", entries: ["a", "b", "c"] } }),
@@ -78,3 +101,84 @@ describe("CleanerWidget", () => {
});
});
describe("CleanerWidget — neg column (send-to-negative)", () => {
function lastEmit(w: ReturnType<typeof mount>): CleanerNodeConfig {
const emits = w.emitted("update:modelValue") ?? [];
return emits[emits.length - 1][0] as CleanerNodeConfig;
}
it("neg toggles default from the preset: fuzzy dedupe + blocklist off", () => {
const w = mount(CleanerWidget, {
props: makeProps({ intensity: "aggressive", blocklist: { kind: "list", entries: ["x"] } }),
});
const on = (rid: string) => w.find(`[data-test="cleaner-neg-rule-${rid}"]`).classes().includes("is-on");
expect(on("whitespace")).toBe(true);
expect(on("dedupe_exact")).toBe(true);
expect(on("fuzzy_dedupe")).toBe(false);
expect(on("blocklist")).toBe(false);
// The prompt column still has both on under aggressive + entries.
expect(w.find('[data-test="cleaner-rule-fuzzy_dedupe"]').classes()).toContain("is-on");
});
it("a neg toggle stores negative_rules_override only when it differs, and marks CUSTOM", async () => {
const w = mount(CleanerWidget, { props: makeProps() });
await w.find('[data-test="cleaner-neg-rule-fuzzy_dedupe"]').trigger("click");
const next = lastEmit(w);
expect(next.negative_rules_override).toEqual({ fuzzy_dedupe: true });
expect(next.rules_override).toEqual({});
const custom = mount(CleanerWidget, { props: makeProps({ negative_rules_override: { fuzzy_dedupe: true } }) });
expect(custom.find('[data-test="cleaner-custom-badge"]').classes()).not.toContain("is-hidden");
// Toggling back to the default drops the key entirely.
await custom.find('[data-test="cleaner-neg-rule-fuzzy_dedupe"]').trigger("click");
expect(lastEmit(custom)).not.toHaveProperty("negative_rules_override");
});
it("a preset prunes neg overrides that match its default", async () => {
const w = mount(CleanerWidget, {
props: makeProps({ intensity: "gentle", negative_rules_override: { dedupe_exact: true } }),
});
await w.find('[data-test="cleaner-intensity-balanced"]').trigger("click");
const next = lastEmit(w);
expect(next.intensity).toBe("balanced");
expect(next).not.toHaveProperty("negative_rules_override");
});
it("drop-overlap row is off by default and stored only when on", async () => {
const w = mount(CleanerWidget, { props: makeProps() });
const btn = w.find('[data-test="cleaner-drop-overlap"]');
expect(btn.classes()).not.toContain("is-on");
await btn.trigger("click");
expect(lastEmit(w).drop_prompt_overlap).toBe(true);
const on = mount(CleanerWidget, { props: makeProps({ drop_prompt_overlap: true }) });
await on.find('[data-test="cleaner-drop-overlap"]').trigger("click");
expect(lastEmit(on)).not.toHaveProperty("drop_prompt_overlap");
});
it("last run shows a negative line and the counter a negative count", () => {
const w = mount(CleanerWidget, {
props: makeProps({}, {
lastRunReport: { dedupe_exact: { dropped: ["a", "b"] }, blocklist: { dropped: ["c"] } },
wordCount: 42,
charCount: 187,
negativeReport: {
dedupe_exact: { dropped: ["x", "y", "z"] },
prompt_overlap: { tags: ["red"], dropped: false },
},
negativeWordCount: 11,
}),
});
expect(w.find('[data-test="cleaner-neg-count"]').text()).toBe("negative 11");
expect(w.find('[data-test="cleaner-last-run"]').text()).toContain("prompt: 2 duplicates, 1 blocklisted");
expect(w.find('[data-test="cleaner-last-run-neg"]').text()).toBe(
'negative: 3 duplicates · 1 tag also in prompt: "red"',
);
});
it("no negative report = no last-run block and no negative count", () => {
const w = mount(CleanerWidget, { props: makeProps() });
expect(w.find('[data-test="cleaner-last-run"]').exists()).toBe(false);
expect(w.find('[data-test="cleaner-neg-count"]').exists()).toBe(false);
});
});
+240 -10
View File
@@ -5,16 +5,27 @@
* in code; per-node manual toggles + blocklist entries are persisted
* via the widget JSON.
*
* Send-to-negative: the rule list has a second "neg" column (the node's
* optional `negative` input is cleaned by it), plus a neg-only "drop
* negative tags also in prompt" row.
*
* Tokens: uses canonical `--wp-*` theme variables from
* src/components/shared/theme.css (and SPA's tokens.css which
* overrides the same names with the SPA palette).
*/
import { computed } from "vue";
import { INTENSITY_TO_RULES, computeEffectiveRules, isPristine } from "./intensity";
import {
INTENSITY_TO_NEG_RULES,
INTENSITY_TO_RULES,
computeEffectiveNegativeRules,
computeEffectiveRules,
isPristine,
} from "./intensity";
import type {
CleanerNodeConfig,
Intensity,
Mode,
NegativeRunReport,
RuleId,
RunReport,
} from "./types";
@@ -28,7 +39,11 @@ const props = withDefaults(defineProps<{
* dim overlay so muted/bypassed state matches litegraph's native
* title/border dim. */
nodeMode?: number;
}>(), { nodeMode: 0 });
/** Last run's negative report; null when the run had no negative. */
negativeReport?: NegativeRunReport | null;
/** Word count of the cleaned negative; null when there was none. */
negativeWordCount?: number | null;
}>(), { nodeMode: 0, negativeReport: null, negativeWordCount: null });
const isSkipped = computed(() => props.nodeMode === 2 || props.nodeMode === 4);
@@ -59,6 +74,24 @@ const ALL_RULES: {
statKey: "stripped",
tooltip: "Drop tags that are only punctuation (e.g. lone '.'). Strip leading/trailing punctuation from each tag (tags mode) or from the whole string (text mode).",
},
{
id: "empty_groups",
label: "empty groups",
statKey: "removed",
tooltip: "Remove brackets with nothing inside: (), [], ( , ) and (:1.2), e.g. a weighted ref that resolved to nothing. Escaped \\( \\) and unbalanced brackets are left alone.",
},
{
id: "merge_weights",
label: "merge weights",
statKey: "merged",
tooltip: "Fold nested weights into one group the way ComfyUI multiplies them: ((a:1.1):1.2) → (a:1.32), ((a)) → (a:1.21). A weight of 1 is unwrapped: (a:1.0) → a. Rounded to 2 decimals.",
},
{
id: "lora_spacing",
label: "LoRA spacing",
statKey: "tidied",
tooltip: "Tidy <lora:…> tags: < lora : x : 0.8 > → <lora:x:0.8>, and give a tag touching a word a space (girl<lora:x:1> → girl <lora:x:1>). Spaces inside a file name stay.",
},
{
id: "dedupe_exact",
label: "tag dedupe",
@@ -88,13 +121,14 @@ const MODE_TOOLTIPS = {
const INTENSITY_TOOLTIPS = {
gentle: "Gentle: whitespace cleanup only.",
balanced: "Balanced: whitespace + punctuation + exact tag dedupe.",
aggressive: "Aggressive: all rules including fuzzy dedupe.",
balanced: "Balanced: empty groups + LoRA spacing + whitespace + punctuation + exact tag dedupe.",
aggressive: "Aggressive: balanced plus merge weights and fuzzy dedupe.",
};
const INTENSITIES: Intensity[] = ["gentle", "balanced", "aggressive"];
const effective = computed(() => new Set(computeEffectiveRules(props.modelValue)));
const effectiveNeg = computed(() => new Set(computeEffectiveNegativeRules(props.modelValue)));
const pristine = computed(() => isPristine(props.modelValue));
/** Drop any override entry whose value matches the new intensity's
@@ -120,11 +154,42 @@ function patch(next: Partial<CleanerNodeConfig>): void {
emit("update:modelValue", { ...props.modelValue, ...next });
}
/** The config with `negative_rules_override` set to `overrides`, or the key
* dropped when nothing diverges (so an untouched column stays absent). */
function withNegOverrides(
base: CleanerNodeConfig,
overrides: Partial<Record<RuleId, boolean>>,
): CleanerNodeConfig {
const next = { ...base };
if (Object.keys(overrides).length > 0) next.negative_rules_override = overrides;
else delete next.negative_rules_override;
return next;
}
/** Neg-column twin of {@link pruneStaleOverrides} (no blocklist baseline). */
function pruneStaleNegOverrides(
overrides: Partial<Record<RuleId, boolean>>,
intensity: Intensity,
): Partial<Record<RuleId, boolean>> {
const defaults = new Set(INTENSITY_TO_NEG_RULES[intensity]);
const next: Partial<Record<RuleId, boolean>> = {};
for (const [rid, on] of Object.entries(overrides) as [RuleId, boolean][]) {
if (on !== defaults.has(rid)) next[rid] = on;
}
return next;
}
function setIntensity(intensity: Intensity): void {
patch({
// A preset sets both columns: overrides that now match its default drop.
const base: CleanerNodeConfig = {
...props.modelValue,
intensity,
rules_override: pruneStaleOverrides(props.modelValue.rules_override, intensity),
});
};
emit("update:modelValue", withNegOverrides(
base,
pruneStaleNegOverrides(props.modelValue.negative_rules_override ?? {}, intensity),
));
}
function setMode(mode: Mode): void { patch({ mode }); }
@@ -161,6 +226,68 @@ function toggleRule(rid: RuleId): void {
patch({ rules_override: overrides });
}
function negBaseline(rid: RuleId): boolean {
return INTENSITY_TO_NEG_RULES[props.modelValue.intensity].includes(rid);
}
function toggleNegRule(rid: RuleId): void {
if (isRuleDisabled(rid)) return;
const nextOn = !effectiveNeg.value.has(rid);
const overrides = { ...(props.modelValue.negative_rules_override ?? {}) };
if (nextOn === negBaseline(rid)) delete overrides[rid];
else overrides[rid] = nextOn;
emit("update:modelValue", withNegOverrides(props.modelValue, overrides));
}
function isNegOverridden(rid: RuleId): boolean {
const v = props.modelValue.negative_rules_override?.[rid];
return v !== undefined && v !== negBaseline(rid);
}
function toggleDropOverlap(): void {
const next = { ...props.modelValue };
if (props.modelValue.drop_prompt_overlap) delete next.drop_prompt_overlap;
else next.drop_prompt_overlap = true;
emit("update:modelValue", next);
}
function droppedCount(report: RunReport | null | undefined, rids: RuleId[]): number {
let n = 0;
for (const rid of rids) {
const v = (report?.[rid] as Record<string, unknown> | undefined)?.dropped;
if (Array.isArray(v)) n += v.length;
else if (typeof v === "number") n += v;
}
return n;
}
function plural(n: number, word: string): string {
return `${n} ${word}${n === 1 ? "" : "s"}`;
}
/** "last run" lines, shown once a run cleaned a negative. */
const promptRunLine = computed(() => {
const parts = [plural(droppedCount(props.lastRunReport, ["dedupe_exact", "fuzzy_dedupe"]), "duplicate")];
const blocked = droppedCount(props.lastRunReport, ["blocklist"]);
if (blocked > 0) parts.push(`${blocked} blocklisted`);
return `prompt: ${parts.join(", ")}`;
});
const negativeRunLine = computed(() => {
const r = props.negativeReport;
if (!r) return "";
const parts = [plural(droppedCount(r, ["dedupe_exact", "fuzzy_dedupe"]), "duplicate")];
const blocked = droppedCount(r, ["blocklist"]);
if (blocked > 0) parts[0] += `, ${blocked} blocklisted`;
const overlap = r.prompt_overlap;
if (overlap && overlap.tags.length > 0) {
const quoted = overlap.tags.map((t) => `"${t}"`).join(", ");
parts.push(
`${plural(overlap.tags.length, "tag")} also in prompt${overlap.dropped ? " (dropped)" : ""}: ${quoted}`,
);
}
return `negative: ${parts.join(" · ")}`;
});
function ruleStat(rid: RuleId): string {
const stats = props.lastRunReport?.[rid];
if (!stats) return "—";
@@ -201,7 +328,11 @@ function isOverridden(rid: RuleId): boolean {
<span
class="wp-cleaner__counter"
:title="`${wordCount} words · ${charCount} characters in the cleaned output`"
>{{ wordCount }}w · {{ charCount }}c</span>
>{{ wordCount }}w · {{ charCount }}c<template v-if="negativeWordCount != null"> · <span
class="wp-cleaner__counter-neg"
data-test="cleaner-neg-count"
:title="`${negativeWordCount} words in the cleaned negative`"
>negative {{ negativeWordCount }}</span></template></span>
</header>
<section class="wp-cleaner__section">
@@ -226,11 +357,14 @@ function isOverridden(rid: RuleId): boolean {
</section>
<section class="wp-cleaner__section">
<span class="wp-cleaner__section-label">RULES</span>
<div class="wp-cleaner__rules-head">
<span class="wp-cleaner__section-label">RULES</span>
<span class="wp-cleaner__col-label">prompt</span>
<span class="wp-cleaner__col-label wp-cleaner__col-label--neg">neg</span>
</div>
<div class="wp-cleaner__rules">
<div v-for="rule in ALL_RULES" :key="rule.id" class="wp-cleaner__rule-row">
<button
v-for="rule in ALL_RULES"
:key="rule.id"
:data-test="`cleaner-rule-${rule.id}`"
:class="['wp-cleaner__rule', {
'is-on': effective.has(rule.id) && !isRuleDisabled(rule.id),
@@ -256,9 +390,46 @@ function isOverridden(rid: RuleId): boolean {
class="wp-cleaner__rule-stat"
>{{ ruleStat(rule.id) }}</span>
</button>
<button
:data-test="`cleaner-neg-rule-${rule.id}`"
:class="['wp-cleaner__neg-toggle', {
'is-on': effectiveNeg.has(rule.id) && !isRuleDisabled(rule.id),
'is-overridden': isNegOverridden(rule.id) && !isRuleDisabled(rule.id),
'is-disabled': isRuleDisabled(rule.id),
}]"
:title="`${rule.label} on the negative${isRuleDisabled(rule.id) ? ' (disabled in text mode)' : ''}`"
:aria-label="`${rule.label} on the negative`"
:aria-pressed="effectiveNeg.has(rule.id)"
:disabled="isRuleDisabled(rule.id)"
@click="toggleNegRule(rule.id)"
><span class="wp-cleaner__rule-dot" /></button>
</div>
<div class="wp-cleaner__rule-row wp-cleaner__rule-row--overlap">
<span
class="wp-cleaner__rule wp-cleaner__rule--static"
title="Drop negative tags the cleaned prompt also contains (it would be told to draw them and not draw them). Negative only."
>
<span class="wp-cleaner__rule-dash" aria-hidden="true">–</span>
<span class="wp-cleaner__rule-label">drop negative tags also in prompt</span>
</span>
<button
data-test="cleaner-drop-overlap"
:class="['wp-cleaner__neg-toggle', { 'is-on': !!modelValue.drop_prompt_overlap }]"
title="Drop negative tags also in the prompt"
aria-label="drop negative tags also in the prompt"
:aria-pressed="!!modelValue.drop_prompt_overlap"
@click="toggleDropOverlap"
><span class="wp-cleaner__rule-dot" /></button>
</div>
</div>
</section>
<div v-if="negativeReport" class="wp-cleaner__lastrun" data-test="cleaner-last-run">
<span class="wp-cleaner__section-label">LAST RUN</span>
<span class="wp-cleaner__lastrun-line">{{ promptRunLine }}</span>
<span class="wp-cleaner__lastrun-line wp-cleaner__lastrun-line--neg" data-test="cleaner-last-run-neg">{{ negativeRunLine }}</span>
</div>
<div class="wp-cleaner__blocklist">
<button
data-test="cleaner-blocklist-btn"
@@ -373,6 +544,65 @@ function isOverridden(rid: RuleId): boolean {
}
.wp-cleaner__rules { display: grid; gap: 2px; }
/* Two columns: the prompt rule button, then the neg-column toggle. */
.wp-cleaner__rules-head,
.wp-cleaner__rule-row {
display: grid;
grid-template-columns: 1fr 30px;
align-items: center;
gap: 4px;
}
.wp-cleaner__rules-head { grid-template-columns: 1fr auto 30px; }
.wp-cleaner__col-label {
font: 600 9px var(--wp-font-sans, sans-serif);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--wp-text-dim, var(--wp-text3));
text-align: center;
}
.wp-cleaner__col-label--neg { color: var(--wp-danger, #ef4444); }
.wp-cleaner__neg-toggle {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 20px;
background: transparent;
border: 0;
border-radius: 3px;
cursor: pointer;
}
.wp-cleaner__neg-toggle:hover:not(.is-disabled) { background: var(--wp-row-hover, var(--wp-bg2)); }
.wp-cleaner__neg-toggle.is-on .wp-cleaner__rule-dot { background: var(--wp-danger, #ef4444); }
.wp-cleaner__neg-toggle.is-overridden .wp-cleaner__rule-dot {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--wp-amber, var(--wp-warn, #fbbf24)) 70%, transparent);
}
.wp-cleaner__neg-toggle.is-disabled { opacity: 0.4; cursor: not-allowed; }
.wp-cleaner__rule-row--overlap {
border-top: 1px solid var(--wp-border-soft, var(--wp-border));
padding-top: 2px;
margin-top: 2px;
}
.wp-cleaner__rule--static { cursor: default; }
.wp-cleaner__rule-dash {
width: 8px;
text-align: center;
color: var(--wp-text-dim, var(--wp-text3));
}
.wp-cleaner__counter-neg { color: var(--wp-danger, #ef4444); }
.wp-cleaner__lastrun {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px solid var(--wp-border);
border-radius: 3px;
font: 10px/1.5 var(--wp-font-mono, monospace);
color: var(--wp-text-muted, var(--wp-text2));
}
.wp-cleaner__lastrun-line { word-break: break-word; }
.wp-cleaner__lastrun-line--neg { color: var(--wp-danger, #ef4444); }
.wp-cleaner__rule {
position: relative;
display: grid;
+44 -4
View File
@@ -1,19 +1,28 @@
import { describe, it, expect } from "vitest";
import { INTENSITY_TO_RULES, computeEffectiveRules, isPristine } from "./intensity";
import {
INTENSITY_TO_NEG_RULES,
INTENSITY_TO_RULES,
computeEffectiveNegativeRules,
computeEffectiveRules,
isPristine,
} from "./intensity";
import { emptyCleanerConfig, type CleanerNodeConfig } from "./types";
describe("intensity helpers", () => {
it("INTENSITY_TO_RULES mirrors the Python pipeline", () => {
expect(INTENSITY_TO_RULES.gentle).toEqual(["whitespace"]);
expect(INTENSITY_TO_RULES.balanced).toEqual([
"whitespace", "punctuation", "dedupe_exact",
"empty_groups", "lora_spacing", "whitespace", "punctuation", "dedupe_exact",
]);
expect(INTENSITY_TO_RULES.aggressive).toEqual([
"empty_groups", "merge_weights", "lora_spacing",
"whitespace", "punctuation", "dedupe_exact", "fuzzy_dedupe",
]);
expect(INTENSITY_TO_RULES.aggressive).toHaveLength(4);
});
it("computeEffectiveRules returns intensity rules when no overrides", () => {
expect(computeEffectiveRules(emptyCleanerConfig())).toEqual([
"whitespace", "punctuation", "dedupe_exact",
"empty_groups", "lora_spacing", "whitespace", "punctuation", "dedupe_exact",
]);
});
@@ -72,6 +81,37 @@ describe("intensity helpers", () => {
})).toBe(true);
});
it("INTENSITY_TO_NEG_RULES mirrors the Python pipeline's negative column", () => {
expect(INTENSITY_TO_NEG_RULES).toEqual({
gentle: ["whitespace"],
balanced: ["empty_groups", "lora_spacing", "whitespace", "punctuation", "dedupe_exact"],
aggressive: [
"empty_groups", "merge_weights", "lora_spacing",
"whitespace", "punctuation", "dedupe_exact",
],
});
});
it("neg column: preset defaults + overrides, never auto-enables the blocklist", () => {
const cfg: CleanerNodeConfig = {
...emptyCleanerConfig(),
intensity: "aggressive",
blocklist: { kind: "list", entries: ["x"] },
};
expect(computeEffectiveNegativeRules(cfg)).toEqual([
"empty_groups", "merge_weights", "lora_spacing", "whitespace", "punctuation", "dedupe_exact",
]);
expect(computeEffectiveNegativeRules({
...cfg, negative_rules_override: { dedupe_exact: false, fuzzy_dedupe: true, merge_weights: false },
})).toEqual(["empty_groups", "lora_spacing", "whitespace", "punctuation", "fuzzy_dedupe"]);
});
it("isPristine false when the neg column diverges from its preset", () => {
expect(isPristine({ ...emptyCleanerConfig(), negative_rules_override: { fuzzy_dedupe: true } })).toBe(false);
// Matching the neg default is not a modification.
expect(isPristine({ ...emptyCleanerConfig(), negative_rules_override: { whitespace: true } })).toBe(true);
});
it("rules sorted by registry order regardless of toggle order", () => {
const cfg: CleanerNodeConfig = {
...emptyCleanerConfig(),

Some files were not shown because too many files have changed in this diff Show More