Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83da31306d | ||
|
|
c90a4a8d4b | ||
|
|
48c0c13994 | ||
|
|
8d24c8822d | ||
|
|
add6506225 | ||
|
|
9b567f38d8 | ||
|
|
c6011f2044 | ||
|
|
394918bfea | ||
|
|
6d43879855 | ||
|
|
57393d6a84 | ||
|
|
855cdae2a8 | ||
|
|
c9325e39cd | ||
|
|
2468f10a3d | ||
|
|
25451e52d5 | ||
|
|
a13bcb8568 | ||
|
|
332ad5ee7e | ||
|
|
967d5c57bb | ||
|
|
c2a583743b | ||
|
|
7a27262fb7 | ||
|
|
0974ff5e97 | ||
|
|
b22b325c0f | ||
|
|
f86b554616 | ||
|
|
5b24559418 | ||
|
|
4d74851ff3 | ||
|
|
7845823dd2 | ||
|
|
610685efea | ||
|
|
195f65d6dd | ||
|
|
c46ce8dba7 | ||
|
|
f7f73252a1 | ||
|
|
adb20455e9 | ||
|
|
c9c20d2f36 | ||
|
|
5b2bb571bf | ||
|
|
bc003617ac | ||
|
|
4cd180ce86 | ||
|
|
7b02fcfd58 | ||
|
|
6b15abe32e | ||
|
|
55ce0559cc | ||
|
|
52c7a3af63 | ||
|
|
96f2f38e8d | ||
|
|
0230f7f4f2 | ||
|
|
2f0f284af1 | ||
|
|
380c99d657 | ||
|
|
ebcae98558 | ||
|
|
db94001a90 | ||
|
|
474762c384 | ||
|
|
cd99ef360c | ||
|
|
a512e071e9 | ||
|
|
f44095c123 | ||
|
|
de15a72a2f | ||
|
|
7545dbe3e5 | ||
|
|
f4bbe104a6 | ||
|
|
05e285783e | ||
|
|
7d1b16f883 | ||
|
|
2222cde45e | ||
|
|
b4e35c3cb0 | ||
|
|
b0aa2e1628 | ||
|
|
455e8883f2 | ||
|
|
9d54b9103a | ||
|
|
4d178e2b7e | ||
|
|
1c81548faf | ||
|
|
871e9b5a7a | ||
|
|
4449bd9c82 | ||
|
|
f73e122069 | ||
|
|
7fb6c1b3a9 | ||
|
|
5699ba8521 | ||
|
|
4b1a3b483f | ||
|
|
889f9a3820 | ||
|
|
ae149c8f77 | ||
|
|
5899fb1299 | ||
|
|
9a2a5d6b2e | ||
|
|
0f9187c569 | ||
|
|
078d738ce4 | ||
|
|
70866d6596 | ||
|
|
b08e34f5c4 | ||
|
|
4954b65638 | ||
|
|
aee42389c4 | ||
|
|
44db79a9e2 | ||
|
|
a1c7b886cf | ||
|
|
0975a627f3 | ||
|
|
9ff8014590 | ||
|
|
43b2f0b3cf | ||
|
|
5b707f7df7 | ||
|
|
40a466ec2c | ||
|
|
aa283b9157 | ||
|
|
f7419f2156 | ||
|
|
87ac907403 | ||
|
|
1df2e487fe | ||
|
|
d6e1a9c2d6 | ||
|
|
bb78e94178 | ||
|
|
4a666fcd29 | ||
|
|
020da57218 | ||
|
|
fbd7b743a9 | ||
|
|
dbc694f4d8 | ||
|
|
3bce46b28d | ||
|
|
c7314e98f2 | ||
|
|
6cdf7996c8 | ||
|
|
aef082964d | ||
|
|
67ee027592 | ||
|
|
c436bd931f | ||
|
|
417e36da5d | ||
|
|
0d7e5b73a4 | ||
|
|
35b401c371 | ||
|
|
b8679a621e | ||
|
|
918ba26b27 | ||
|
|
9f0f38a0a6 | ||
|
|
4afbb5a08e | ||
|
|
6d8b609454 | ||
|
|
7960d46085 | ||
|
|
ad4b78cfbb | ||
|
|
a54c525bf6 | ||
|
|
06231cf226 | ||
|
|
0a7f4f4386 | ||
|
|
82d5bcaa1b | ||
|
|
8b6b6ad6d2 | ||
|
|
27943d8da3 | ||
|
|
38f539d400 | ||
|
|
bd3fbd54c2 | ||
|
|
eb18825853 | ||
|
|
58360c75f8 | ||
|
|
934f7dc92d | ||
|
|
caffb9824f | ||
|
|
fd6642b258 | ||
|
|
1cb59b2159 | ||
|
|
afa4e47bb8 | ||
|
|
1486785df1 | ||
|
|
b765235f2b | ||
|
|
fac9c4df4b | ||
|
|
69a855291c | ||
|
|
df252c736a | ||
|
|
2cf7f7e8c3 | ||
|
|
0fce54165e | ||
|
|
f4c8411fb8 | ||
|
|
6aadcf0575 | ||
|
|
c4e1663210 | ||
|
|
13acd9a17d | ||
|
|
a29c0fc564 | ||
|
|
7b820d193d | ||
|
|
dc119ad6bd | ||
|
|
7e6e373076 | ||
|
|
e0b4ab0bcc | ||
|
|
27f6244142 | ||
|
|
726cca5d91 | ||
|
|
6d19bd94f4 | ||
|
|
2e9a5a5b6a | ||
|
|
f487cc82a3 | ||
|
|
995dc4d17a | ||
|
|
393004bc54 | ||
|
|
93a05c948e | ||
|
|
fe7f12e65d | ||
|
|
05d53a0c6c | ||
|
|
b55a9794a3 | ||
|
|
c7c761424a | ||
|
|
1e4398ae29 | ||
|
|
f0117d4230 | ||
|
|
5d910f5f39 | ||
|
|
14edfa3458 | ||
|
|
9fe33ab4b2 | ||
|
|
2f64f6c7ac | ||
|
|
f4444394e5 | ||
|
|
b6e6f55cce | ||
|
|
c811d6bc62 | ||
|
|
7cd08f070c | ||
|
|
1c305e4fb1 | ||
|
|
f23a9886d4 | ||
|
|
45c7bf50de | ||
|
|
8929b2e5cb | ||
|
|
7523aae41e | ||
|
|
ea16a06991 | ||
|
|
ef30b366ce | ||
|
|
70bd479b83 | ||
|
|
4b2a76e72b | ||
|
|
e8b75a11a9 | ||
|
|
cce1ac4dae | ||
|
|
27cbf5fa26 | ||
|
|
fc920b4d3c | ||
|
|
d604deb6a5 | ||
|
|
8cf40241cf | ||
|
|
72e96ec73f | ||
|
|
5d593a1700 | ||
|
|
81495ea530 | ||
|
|
25527c37ff | ||
|
|
09257471f8 | ||
|
|
09b0e0ddd3 | ||
|
|
bc8421fed3 | ||
|
|
5c164bb6ee | ||
|
|
28487f1a68 | ||
|
|
91e426b595 | ||
|
|
17cefad53c | ||
|
|
a723243d3d | ||
|
|
51bc4893ec | ||
|
|
bb40882238 | ||
|
|
bb91476f9f | ||
|
|
3de5b6a924 | ||
|
|
ca87384b5b | ||
|
|
8aea38dd45 | ||
|
|
e2b9f2f053 | ||
|
|
a4a89e5694 | ||
|
|
107e1de41e | ||
|
|
b8957acf34 | ||
|
|
36969d089a | ||
|
|
51db05620d | ||
|
|
b2fedcd87a | ||
|
|
e7feeaa112 | ||
|
|
33c2e718c1 | ||
|
|
b4d764fd4e | ||
|
|
fd9ba60bec | ||
|
|
0d01a633d0 | ||
|
|
39266cb87b | ||
|
|
ff6332cf23 | ||
|
|
49f882f131 | ||
|
|
eb9167e1c1 | ||
|
|
fa9cde0546 | ||
|
|
c3e74e206d | ||
|
|
494b53192d | ||
|
|
8c0f3e418e | ||
|
|
d2f548d0e2 | ||
|
|
7e36bb02c6 | ||
|
|
3a5e46cd9d | ||
|
|
757116dc54 | ||
|
|
05334d3e05 | ||
|
|
2d68c61f4a | ||
|
|
c6d8834a42 | ||
|
|
99d3b21334 | ||
|
|
e9a9d51ea3 | ||
|
|
0572eec472 | ||
|
|
c7f4d91a10 | ||
|
|
45a9092bbc | ||
|
|
2dfed26c1e | ||
|
|
5585583765 | ||
|
|
c4a761d071 | ||
|
|
736e152167 | ||
|
|
a2756f6c0a | ||
|
|
15c3474519 | ||
|
|
87382e650e | ||
|
|
f240aeaf05 | ||
|
|
911e9839a4 | ||
|
|
cc6d76da3f | ||
|
|
6bd6140ae9 | ||
|
|
433930d005 | ||
|
|
d381cc4b06 | ||
|
|
f52d4271dc | ||
|
|
6cf4990513 | ||
|
|
ffd0a5e709 | ||
|
|
845e03c877 | ||
|
|
de361d3fab | ||
|
|
548b63edc9 | ||
|
|
51286b3273 | ||
|
|
157606b082 | ||
|
|
2f40c921eb | ||
|
|
4b69068ace | ||
|
|
0e1b36f6e4 | ||
|
|
dafb7d879c | ||
|
|
6332ed1e4f | ||
|
|
d9e4a7c262 | ||
|
|
0765df3579 | ||
|
|
2b00a5970c | ||
|
|
22b6597ec7 | ||
|
|
c421675102 | ||
|
|
021b411e3c | ||
|
|
312e1575ae | ||
|
|
9e8cd23fe5 | ||
|
|
a769c0e496 | ||
|
|
99125a63d7 | ||
|
|
2768570660 | ||
|
|
12d6f99104 | ||
|
|
97a50ff452 | ||
|
|
b712394648 | ||
|
|
72d9a17f89 | ||
|
|
01de4f0f10 | ||
|
|
7f4b7b93f1 | ||
|
|
a3a0e3f95b | ||
|
|
890d868e16 | ||
|
|
530714e62b | ||
|
|
dd1d5d429f | ||
|
|
1f07d6eb13 | ||
|
|
e5b3edba43 | ||
|
|
4b53aca434 | ||
|
|
6981314fb5 | ||
|
|
b3c4270f6c | ||
|
|
54b525cfa0 | ||
|
|
06bb1b39d7 | ||
|
|
77fc32d760 | ||
|
|
d221ea479f | ||
|
|
b09400ecd6 | ||
|
|
685d6593fd | ||
|
|
4fc3cd84d0 | ||
|
|
88e75ac60c | ||
|
|
7bf39d9337 | ||
|
|
ea188af729 | ||
|
|
385e7b7778 | ||
|
|
e5cc122341 | ||
|
|
09227b216c | ||
|
|
8f388adc55 | ||
|
|
2f08c138ac | ||
|
|
6cbff32827 | ||
|
|
7e26211fa5 | ||
|
|
61288c5c2e | ||
|
|
22f7dedcf2 | ||
|
|
96c65a7d1b | ||
|
|
1224d35010 | ||
|
|
78f831ad69 | ||
|
|
acb5848da3 | ||
|
|
2c6de11c8c | ||
|
|
fe6f6ec451 | ||
|
|
0f4ba28105 | ||
|
|
727bd089a4 | ||
|
|
470133f953 | ||
|
|
388af28a2b | ||
|
|
7c18fcc208 | ||
|
|
4cf497b507 | ||
|
|
51eabadc36 | ||
|
|
96b4141aed | ||
|
|
963c36fc18 | ||
|
|
59e51d34c0 | ||
|
|
4e746ce2aa | ||
|
|
8d6d4e4b10 | ||
|
|
7f07f99951 | ||
|
|
e35ebb25a7 | ||
|
|
693f7191d0 | ||
|
|
2896e11d26 | ||
|
|
dd793c536e | ||
|
|
6c4589315a | ||
|
|
3f6b856eec | ||
|
|
bbc7556af2 | ||
|
|
5064f084a3 | ||
|
|
67761fbba8 | ||
|
|
630129bc32 | ||
|
|
ba170939e8 | ||
|
|
fd3c3805b6 | ||
|
|
7cb2da2468 | ||
|
|
aa1ae66821 | ||
|
|
f922a872cc | ||
|
|
23486ff1ef | ||
|
|
2fa4618ef9 | ||
|
|
50211e510b | ||
|
|
d7a72d7434 | ||
|
|
7d443ba00b | ||
|
|
1787b6041c | ||
|
|
fd4c501871 | ||
|
|
9fb653f7ee | ||
|
|
3f45b26396 | ||
|
|
44d0ca2261 | ||
|
|
699dedade0 | ||
|
|
a64e56d083 | ||
|
|
5a83e61fd1 | ||
|
|
c268e75c20 | ||
|
|
e63be54081 | ||
|
|
ecebd55654 | ||
|
|
78297c4179 | ||
|
|
bb5f6b8658 | ||
|
|
a059b48635 | ||
|
|
9ef1fb698a | ||
|
|
1b32a8a110 | ||
|
|
31b011b8fb | ||
|
|
a2e93fe7e4 | ||
|
|
1e42f9b5aa | ||
|
|
67199b5dba | ||
|
|
74c7331b7e | ||
|
|
00b735d32f | ||
|
|
a1fe6956d1 | ||
|
|
fe2423f5b4 | ||
|
|
6c20f53cc6 | ||
|
|
936a13913e | ||
|
|
290e7a81f5 | ||
|
|
327e7cc762 | ||
|
|
830776efd1 | ||
|
|
bdcf85ccd4 | ||
|
|
d42b7ca1d8 | ||
|
|
fcde3b31d6 | ||
|
|
ea4ed059c3 | ||
|
|
f670c3db76 | ||
|
|
af95f08ef4 | ||
|
|
70283ce4d3 | ||
|
|
bab87cd3c2 | ||
|
|
7545ade87d | ||
|
|
651ca65228 | ||
|
|
d524d142b5 | ||
|
|
da2239dca5 | ||
|
|
9d372b1d8f | ||
|
|
7aab09d6bc | ||
|
|
2295ce0f81 | ||
|
|
8ea15719bd | ||
|
|
d03936478c | ||
|
|
8dcef0f3d9 | ||
|
|
8912937a9a | ||
|
|
d4e6d0c112 | ||
|
|
696711dca1 | ||
|
|
b54302d546 | ||
|
|
0bd4382cf0 | ||
|
|
ed10b881b7 | ||
|
|
cbf102b8a0 | ||
|
|
be70a5f149 | ||
|
|
7d2bd63912 | ||
|
|
6f69304796 | ||
|
|
6802f11497 | ||
|
|
f888acdcd3 | ||
|
|
41fae1e41b | ||
|
|
bf61f649fa | ||
|
|
fc9d8a85a2 | ||
|
|
6f664ce9e9 | ||
|
|
5d715fba99 | ||
|
|
e98aca30b3 | ||
|
|
df76abdbb0 | ||
|
|
8568aa2c1e | ||
|
|
aba4e8c8ac | ||
|
|
ff6825cedd | ||
|
|
0ede205530 | ||
|
|
a738618dd3 | ||
|
|
8dcebdcc55 | ||
|
|
1b3e4aa61f | ||
|
|
0281b8e321 | ||
|
|
58c5b27922 | ||
|
|
4a34c9bbdd | ||
|
|
06c380fd01 | ||
|
|
c48aafb143 | ||
|
|
ff4d8faa1f | ||
|
|
c7c867e531 | ||
|
|
2fb853fa81 | ||
|
|
e1885a3115 | ||
|
|
f38a316ccb | ||
|
|
e56fd22bc9 | ||
|
|
b33afd8797 | ||
|
|
67a09df6d6 | ||
|
|
1f164df0c5 | ||
|
|
91b5f7581a | ||
|
|
3527ada397 | ||
|
|
25388b4650 | ||
|
|
6eb4b730cb | ||
|
|
942834d16a | ||
|
|
03796c56ba | ||
|
|
65683aed7c | ||
|
|
8a9dc9efbd | ||
|
|
8f696d6eec | ||
|
|
1822297a9a | ||
|
|
21f285033d | ||
|
|
bb5e84d3b2 | ||
|
|
b292699487 | ||
|
|
39fdb6f187 | ||
|
|
3c492f1cb9 | ||
|
|
79cf3857ad | ||
|
|
e93f7ba06e | ||
|
|
4e71e043e7 | ||
|
|
7b416ad0ed | ||
|
|
ece6d49929 | ||
|
|
6dc033d57e | ||
|
|
9663636d10 | ||
|
|
8910b98a86 | ||
|
|
10dcac4100 | ||
|
|
15c1ade3ef | ||
|
|
8871a2806d | ||
|
|
072e4e7836 | ||
|
|
0f7aaabe3c | ||
|
|
f1451baf40 | ||
|
|
589f45eb74 | ||
|
|
fa73750ca0 | ||
|
|
b49a579b33 | ||
|
|
34ec18feff | ||
|
|
44d8ab9575 | ||
|
|
68910bfc18 | ||
|
|
b5262c22d5 | ||
|
|
fb19e3cef8 | ||
|
|
43d775ec8f | ||
|
|
86eb15cfa3 | ||
|
|
0f0cd47018 | ||
|
|
1ad7e8e415 | ||
|
|
5a3e834c42 | ||
|
|
cc64ca3ab8 | ||
|
|
fd2cdcbf08 | ||
|
|
48845981a1 | ||
|
|
5da64f8d78 | ||
|
|
fe0d702e75 | ||
|
|
d3e25b72ad | ||
|
|
40980093e8 | ||
|
|
538db06efc | ||
|
|
eac852fbda | ||
|
|
0bbf150ca3 | ||
|
|
c0f354cb8f | ||
|
|
b3537ad6c5 | ||
|
|
a77e60b402 | ||
|
|
c1f45a3fd2 | ||
|
|
11d205bf61 | ||
|
|
d628a0687d | ||
|
|
02cc9f766f | ||
|
|
a1d051172d | ||
|
|
5b5c635be6 | ||
|
|
ccb8392219 | ||
|
|
d8c20728cb | ||
|
|
0e9e5db486 | ||
|
|
a9516996b4 | ||
|
|
02a046321f | ||
|
|
6a5a8d318e | ||
|
|
a19e5f4500 | ||
|
|
c43bff0488 |
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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-
|
||||
|
||||
+849
@@ -1,3 +1,852 @@
|
||||
## 🎉 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)
|
||||
|
||||
@@ -45,8 +45,10 @@ ruff check . # Python lint
|
||||
- **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.
|
||||
- **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`).
|
||||
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 420 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 +60,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 +84,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–v8 are additive and need no migrator.
|
||||
- **`MAX_KNOWN_SCHEMA_VERSION` = 8** (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).
|
||||
|
||||
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–v8 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 +104,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 v8 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 +143,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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</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">
|
||||
@@ -16,141 +16,133 @@
|
||||
</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. |
|
||||
| **WP Context Injector** | Turns any ComfyUI output (text, a number) into a `$variable`. |
|
||||
| **WP Prompt Cleaner** | Tidies the final prompt: spacing, stray commas, 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, and
|
||||
publish your own.
|
||||
- **Import / Export:** move modules between machines as JSON files.
|
||||
- **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
|
||||
|
||||
## Inspecting a run
|
||||
- [Discord](https://discord.gg/BFYR9WQdVR) for questions and sharing what you made
|
||||
- [Discussions](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/discussions) for ideas and longer questions
|
||||
- [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues) for bugs and feature requests
|
||||
|
||||
Wire a `WP Debug` node off any Context output. After Generate it fills with a tabbed snapshot of what flowed through that point:
|
||||
## Privacy
|
||||
|
||||
- **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.).
|
||||
|
||||
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)
|
||||
|
||||
+65
-1
@@ -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,6 +29,63 @@ 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
|
||||
|
||||
+14
@@ -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)
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -5,10 +5,16 @@ Drops duplicate tags, strips orphan punctuation, filters blocklisted words. Oper
|
||||
## 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: whitespace, punctuation and tag dedupe on (gentle: whitespace only); 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
|
||||
|
||||
|
||||
@@ -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).
|
||||
+113
-81
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -31,8 +31,23 @@ 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": ["whitespace", "punctuation", "dedupe_exact"],
|
||||
"aggressive": ["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 +85,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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;")
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
+26
-4
@@ -431,14 +431,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 +448,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 +478,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 +504,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]:
|
||||
@@ -816,6 +836,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 +845,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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -16,16 +16,33 @@ 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 = 8
|
||||
|
||||
# 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
|
||||
|
||||
_CHAIN = {
|
||||
0: migrate_v0_to_v1,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""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:
|
||||
|
||||
- ``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,
|
||||
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_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_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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,175 @@ 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 _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 +507,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 +523,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 +549,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 +571,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,6 +683,38 @@ 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
|
||||
for i, opt in enumerate(options):
|
||||
@@ -466,6 +760,13 @@ 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"
|
||||
)
|
||||
weight = opt.get("weight", 1)
|
||||
if not isinstance(weight, (int, float)) or isinstance(weight, bool):
|
||||
raise ValueError(
|
||||
@@ -546,15 +847,36 @@ class WildcardHandler(ModuleHandler):
|
||||
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:
|
||||
# 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)
|
||||
_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": "pinned", "option_id": pinned.get("id")})
|
||||
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)}
|
||||
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: pinned target is missing — fall through to random.
|
||||
|
||||
# `category_filter` narrows the option pool to entries whose tag
|
||||
@@ -580,6 +902,15 @@ class WildcardHandler(ModuleHandler):
|
||||
allowed = set(enabled)
|
||||
options = [o for o in options if o.get("id") in allowed]
|
||||
|
||||
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 +945,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 +969,19 @@ 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:
|
||||
warn_excludes_all(options, my_id or "", ctx["__wp_warnings__"])
|
||||
if detail is not None:
|
||||
_explain_pool(detail, options)
|
||||
_explain_constraints(detail, applied_constraints)
|
||||
|
||||
# Effective seed selection:
|
||||
# - locked_seed when present → reproducible per-instance
|
||||
@@ -645,7 +991,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:
|
||||
@@ -688,30 +1047,81 @@ 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)
|
||||
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")
|
||||
detail["chance"] = _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 +1154,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}
|
||||
|
||||
@@ -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()
|
||||
+343
-27
@@ -13,8 +13,10 @@ 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
|
||||
|
||||
@@ -194,30 +196,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 +239,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 +325,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 +475,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 +546,38 @@ class PipelineEngine:
|
||||
else:
|
||||
# Generic object: pull known snapshot keys via getattr so
|
||||
# class-level attributes (common in tests) are visible.
|
||||
# `iteration_overrides` is load-bearing — the per-frame
|
||||
# override patch below reads it back off `raw`. `disabled_frames`
|
||||
# is listed for snapshot honesty only (the skip gate already
|
||||
# read it module-direct above).
|
||||
raw = {
|
||||
k: getattr(module, k)
|
||||
for k in (
|
||||
"id", "type", "enabled", "payload", "entries",
|
||||
"meta", "library_id", "library_snapshot_at",
|
||||
"library_version_at_snapshot", "name", "category_id",
|
||||
"instance",
|
||||
"instance", "iteration_overrides",
|
||||
"disabled_frames", "frame_enabled",
|
||||
)
|
||||
if hasattr(module, k)
|
||||
}
|
||||
snapshot = coerce_legacy_module(raw)
|
||||
# Per-frame iteration override: patch snapshot["instance"]
|
||||
# for the current loop index BEFORE the handler runs.
|
||||
# iteration_overrides is a dict keyed by stringified index;
|
||||
# the matching partial dict is shallow-merged into instance.
|
||||
_ov = (
|
||||
raw.get("iteration_overrides")
|
||||
if isinstance(raw, dict)
|
||||
else getattr(module, "iteration_overrides", None)
|
||||
)
|
||||
if isinstance(_ov, dict):
|
||||
_frame_key = str(ctx.get("__wp_loop_index__", 0))
|
||||
_patch = _ov.get(_frame_key)
|
||||
if isinstance(_patch, dict) and isinstance(
|
||||
snapshot.get("instance"), dict
|
||||
):
|
||||
snapshot["instance"] = {**snapshot["instance"], **_patch}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to coerce module %r at index %s: %s", module, index, e
|
||||
@@ -350,6 +592,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 +629,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 +647,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 +681,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 +701,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 +754,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 +767,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 +891,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
@@ -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"]
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
@@ -33,6 +33,10 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
"category_filter": "string",
|
||||
"exclude_null": "boolean",
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: "hold" pins RNG to the run base seed (same
|
||||
# across loop iterations, re-rolls per run); "vary" (default/absent)
|
||||
# rides the per-iteration seed. `locked_seed` takes priority when set.
|
||||
"seed_scope": "string",
|
||||
"internal": "boolean",
|
||||
# SP2a multi-select: per-instance count range (pick_min..pick_max; a
|
||||
# range other than 1..1 resolves N options without replacement into a
|
||||
@@ -59,6 +63,8 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
# resolution gives fixed_values seed-lock parity with wildcard
|
||||
# + combine. Locked seed pins the alternation outcome.
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: hold/vary control (parity with wildcard).
|
||||
"seed_scope": "string",
|
||||
},
|
||||
"combine": {
|
||||
# v2 single-pane modal exposes a richer override surface:
|
||||
@@ -69,6 +75,8 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
"template_override": "string",
|
||||
"variable_binding": "string",
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: hold/vary control (parity with wildcard).
|
||||
"seed_scope": "string",
|
||||
"internal": "boolean",
|
||||
},
|
||||
"derivation": {
|
||||
|
||||
+23
-3
@@ -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")
|
||||
|
||||
+305
-18
@@ -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,14 +650,12 @@ 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 []
|
||||
@@ -422,6 +669,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 +687,39 @@ 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:
|
||||
warn_excludes_all(options, uuid, ctx.warnings)
|
||||
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 +800,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 +818,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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+7
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.3.0",
|
||||
"version": "2.18.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
@@ -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 |
@@ -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 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "2.3.0"
|
||||
version = "2.18.0"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -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");
|
||||
@@ -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())
|
||||
+105
-10
@@ -157,14 +157,107 @@ 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.
|
||||
const TOTAL_LIMIT = 420 * 1024; // 420 KB
|
||||
|
||||
function gzipSize(path) {
|
||||
return gzipSync(readFileSync(path)).length;
|
||||
@@ -176,7 +269,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 +291,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`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -61,22 +61,32 @@ _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},
|
||||
],
|
||||
},
|
||||
),
|
||||
"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 +103,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 +130,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},
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
@@ -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");
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
// 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 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 { 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");
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -78,3 +78,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -95,6 +110,7 @@ const INTENSITY_TOOLTIPS = {
|
||||
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 +136,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 +208,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 +310,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 +339,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 +372,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 +526,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;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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", () => {
|
||||
@@ -72,6 +78,32 @@ 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: ["whitespace", "punctuation", "dedupe_exact"],
|
||||
aggressive: ["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(["whitespace", "punctuation", "dedupe_exact"]);
|
||||
expect(computeEffectiveNegativeRules({
|
||||
...cfg, negative_rules_override: { dedupe_exact: false, fuzzy_dedupe: true },
|
||||
})).toEqual(["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(),
|
||||
|
||||
@@ -9,6 +9,15 @@ export const INTENSITY_TO_RULES: Record<Intensity, RuleId[]> = {
|
||||
aggressive: ["whitespace", "punctuation", "dedupe_exact", "fuzzy_dedupe"],
|
||||
};
|
||||
|
||||
/** Mirrors `engine/cleaner/pipeline.INTENSITY_TO_NEG_RULES`: the "neg"
|
||||
* column's defaults. Fuzzy dedupe stays off (near-duplicates in a negative
|
||||
* are usually deliberate) and the blocklist never auto-enables. */
|
||||
export const INTENSITY_TO_NEG_RULES: Record<Intensity, RuleId[]> = {
|
||||
gentle: ["whitespace"],
|
||||
balanced: ["whitespace", "punctuation", "dedupe_exact"],
|
||||
aggressive: ["whitespace", "punctuation", "dedupe_exact"],
|
||||
};
|
||||
|
||||
/** Same canonical order as engine/cleaner/rules/__init__.py:RULE_REGISTRY. */
|
||||
const REGISTRY_ORDER: RuleId[] = [
|
||||
"whitespace",
|
||||
@@ -37,6 +46,17 @@ export function computeEffectiveRules(config: CleanerNodeConfig): RuleId[] {
|
||||
return REGISTRY_ORDER.filter((rid) => base.has(rid));
|
||||
}
|
||||
|
||||
/** The "neg" column's rule list (mirrors `run_negative`): preset defaults
|
||||
* plus `negative_rules_override`, no blocklist auto-enable. */
|
||||
export function computeEffectiveNegativeRules(config: CleanerNodeConfig): RuleId[] {
|
||||
const base = new Set<RuleId>(INTENSITY_TO_NEG_RULES[config.intensity]);
|
||||
for (const [rid, on] of Object.entries(config.negative_rules_override ?? {}) as Array<[RuleId, boolean]>) {
|
||||
if (on) base.add(rid);
|
||||
else base.delete(rid);
|
||||
}
|
||||
return REGISTRY_ORDER.filter((rid) => base.has(rid));
|
||||
}
|
||||
|
||||
/** True when no rule override diverges from the intensity baseline.
|
||||
* Blocklist entries on their own do NOT count as "modification" —
|
||||
* they're data, not a toggle. The CUSTOM badge appears only when the
|
||||
@@ -48,5 +68,9 @@ export function isPristine(config: CleanerNodeConfig): boolean {
|
||||
const baseline = defaults.has(rid) || (rid === "blocklist" && hasEntries);
|
||||
if (on !== baseline) return false;
|
||||
}
|
||||
const negDefaults = new Set(INTENSITY_TO_NEG_RULES[config.intensity]);
|
||||
for (const [rid, on] of Object.entries(config.negative_rules_override ?? {}) as Array<[RuleId, boolean]>) {
|
||||
if (on !== negDefaults.has(rid)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -17,11 +17,25 @@ export interface CleanerNodeConfig {
|
||||
/** Sparse — only rules diverging from the intensity default. */
|
||||
rules_override: Partial<Record<RuleId, boolean>>;
|
||||
blocklist: { kind: BlocklistKind; entries: string[] };
|
||||
/** Send-to-negative: the rule list's "neg" column. Sparse like
|
||||
* `rules_override`, against `INTENSITY_TO_NEG_RULES`. Absent on
|
||||
* workflows saved before the column existed. */
|
||||
negative_rules_override?: Partial<Record<RuleId, boolean>>;
|
||||
/** Drop negative tags the cleaned prompt also contains. Off (absent) by
|
||||
* default; stored only when on. */
|
||||
drop_prompt_overlap?: boolean;
|
||||
}
|
||||
|
||||
export type RuleStats = Record<string, unknown>;
|
||||
export type RunReport = Partial<Record<RuleId, RuleStats>>;
|
||||
|
||||
/** `wp_cleaner_negative_report`: the negative column's per-rule stats plus
|
||||
* the tags the negative shares with the prompt (present only when some
|
||||
* do). */
|
||||
export type NegativeRunReport = RunReport & {
|
||||
prompt_overlap?: { tags: string[]; dropped: boolean };
|
||||
};
|
||||
|
||||
export function emptyCleanerConfig(): CleanerNodeConfig {
|
||||
return {
|
||||
mode: "tags",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ContextLoopWidget from "./ContextLoopWidget.vue";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
import { emptyContextLoopConfig } from "./types";
|
||||
import { currentFrame } from "./frame-cursor";
|
||||
import { deriveLoopSeeds } from "../shared/seed-derive";
|
||||
|
||||
describe("ContextLoopWidget", () => {
|
||||
it("renders defaults — hash chip active, both switches off, internal toggles on", () => {
|
||||
@@ -89,3 +92,215 @@ describe("ContextLoopWidget", () => {
|
||||
expect(w.find('[data-test="loop-iteration-internal"]').classes()).toContain("wp-loop__pi-btn--on");
|
||||
});
|
||||
});
|
||||
|
||||
function ws(extra: Partial<ReturnType<typeof emptyContextLoopConfig>> = {}) {
|
||||
return mount(ContextLoopWidget, {
|
||||
props: { modelValue: { ...emptyContextLoopConfig(), ...extra }, baseSeed: 42, count: 4 },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("ContextLoopWidget seeds button", () => {
|
||||
it("renders the Per-iteration seeds button", () => {
|
||||
expect(ws().find('[data-test="loop-seeds-btn"]').exists()).toBe(true);
|
||||
});
|
||||
it("badge shows the locked count when >0", () => {
|
||||
expect(ws({ seed_locks: { "1": 9, "2": 8 } }).find('[data-test="loop-seeds-badge"]').text()).toMatch(/2 locked/i);
|
||||
});
|
||||
it("hides the badge when nothing is locked", () => {
|
||||
expect(ws().find('[data-test="loop-seeds-badge"]').exists()).toBe(false);
|
||||
});
|
||||
it("clicking opens the modal", async () => {
|
||||
const wr = ws();
|
||||
await wr.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(wr.findComponent(SeedListModal).exists()).toBe(true);
|
||||
});
|
||||
it("capitalizes the bypass row label", () => {
|
||||
expect(ws().text()).toContain("Bypass loop");
|
||||
});
|
||||
it("passes the override hint to the modal only when Override Context seed is off", async () => {
|
||||
const off = ws(); // override_seed false by default → nudge to turn it on
|
||||
await off.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(off.findComponent(SeedListModal).props("overrideHint")).toBeTruthy();
|
||||
|
||||
const on = ws({ override_seed: true });
|
||||
await on.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(on.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextLoopWidget frame selector", () => {
|
||||
beforeEach(() => { currentFrame.value = null; });
|
||||
function fw(count = 3) {
|
||||
return mount(ContextLoopWidget, {
|
||||
props: { modelValue: emptyContextLoopConfig(), count },
|
||||
attachTo: document.body, global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
it("renders base + one chip per iteration", () => {
|
||||
expect(fw(3).findAll('[data-test^="loop-frame-"]')).toHaveLength(4);
|
||||
});
|
||||
it("clicking a frame chip sets the shared cursor; base clears it", async () => {
|
||||
const wr = fw(3);
|
||||
await wr.find('[data-test="loop-frame-2"]').trigger("click");
|
||||
expect(currentFrame.value).toBe(1);
|
||||
await wr.find('[data-test="loop-frame-base"]').trigger("click");
|
||||
expect(currentFrame.value).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextLoopWidget bypass (#13)", () => {
|
||||
it("shows an N-bypassed badge on the seeds button", () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [1, 3] };
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 5 } });
|
||||
expect(w.find('[data-test="loop-seeds-bypass-badge"]').text()).toMatch(/2 bypassed/i);
|
||||
});
|
||||
it("marks bypassed edit-frame chips with a dashed-border class", () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [1, 3] };
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 5 } });
|
||||
// frame index 1 -> chip #2 (data-test loop-frame-2)
|
||||
expect(w.find('[data-test="loop-frame-2"]').classes()).toContain("wp-fchips__chip--bypassed");
|
||||
expect(w.find('[data-test="loop-frame-4"]').classes()).toContain("wp-fchips__chip--bypassed");
|
||||
expect(w.find('[data-test="loop-frame-1"]').classes()).not.toContain("wp-fchips__chip--bypassed");
|
||||
});
|
||||
|
||||
it("has no bypass badge when none are bypassed", () => {
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: emptyContextLoopConfig(), count: 5 } });
|
||||
expect(w.find('[data-test="loop-seeds-bypass-badge"]').exists()).toBe(false);
|
||||
});
|
||||
it("writes bypass changes from the modal back into the config", async () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [] };
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 4 } });
|
||||
await w.get('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
w.findComponent(SeedListModal).vm.$emit("update:bypassFrames", [2]);
|
||||
const calls = w.emitted("update:modelValue")!;
|
||||
const last = calls[calls.length - 1][0] as typeof cfg;
|
||||
expect(last.bypass_frames).toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Modifier-click on the frame chips. Both states were already visible here but
|
||||
* only changeable inside the modal, two clicks away and covering the node you
|
||||
* were reading.
|
||||
*/
|
||||
describe("ContextLoopWidget frame chip modifier-clicks", () => {
|
||||
beforeEach(() => { currentFrame.value = null; });
|
||||
|
||||
function fw(cfgPatch: Partial<ReturnType<typeof emptyContextLoopConfig>> = {}, count = 4) {
|
||||
return mount(ContextLoopWidget, {
|
||||
props: { modelValue: { ...emptyContextLoopConfig(), ...cfgPatch }, count, baseSeed: 42 },
|
||||
});
|
||||
}
|
||||
const lastEmit = (w: ReturnType<typeof fw>) => {
|
||||
const calls = w.emitted("update:modelValue");
|
||||
return calls?.[calls.length - 1]?.[0] as ReturnType<typeof emptyContextLoopConfig>;
|
||||
};
|
||||
|
||||
it("alt-click locks the frame to the seed it would have used", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { altKey: true });
|
||||
const next = lastEmit(w);
|
||||
// Frame index 2, and the value must be a real derived seed — not a
|
||||
// placeholder, or the lock would silently change the output it froze.
|
||||
expect(Object.keys(next.seed_locks!)).toEqual(["2"]);
|
||||
expect(Number.isInteger(next.seed_locks!["2"])).toBe(true);
|
||||
});
|
||||
|
||||
it("alt-click on a locked frame unlocks it", async () => {
|
||||
const w = fw({ seed_locks: { "2": 777 } });
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { altKey: true });
|
||||
expect(lastEmit(w).seed_locks).toEqual({});
|
||||
});
|
||||
|
||||
it("the chip-lock value matches what the modal's Lock all would write", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
|
||||
const chipValue = lastEmit(w).seed_locks!["1"];
|
||||
expect(chipValue).toBe(deriveLoopSeeds(42, 4, "hash_index")[1]);
|
||||
});
|
||||
|
||||
it("ctrl-click bypasses a frame, and again re-enables it", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { ctrlKey: true });
|
||||
expect(lastEmit(w).bypass_frames).toEqual([1]);
|
||||
|
||||
const w2 = fw({ bypass_frames: [1] });
|
||||
await w2.find('[data-test="loop-frame-2"]').trigger("click", { ctrlKey: true });
|
||||
expect(lastEmit(w2).bypass_frames).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps bypassed indices sorted so the config does not churn", async () => {
|
||||
const w = fw({ bypass_frames: [3] });
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { ctrlKey: true });
|
||||
expect(lastEmit(w).bypass_frames).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("refuses to bypass the last running frame — a loop needs one", async () => {
|
||||
const w = fw({ bypass_frames: [0, 1, 2] }, 4);
|
||||
await w.find('[data-test="loop-frame-4"]').trigger("click", { ctrlKey: true });
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still lets the last running frame be re-enabled and locked", async () => {
|
||||
const w = fw({ bypass_frames: [0, 1, 2] }, 4);
|
||||
await w.find('[data-test="loop-frame-1"]').trigger("click", { ctrlKey: true });
|
||||
expect(lastEmit(w).bypass_frames).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
// Moving the editor to another frame as a side effect of locking one is the
|
||||
// kind of surprise that makes people stop trusting a shortcut.
|
||||
it("a modifier click never also moves the edit cursor", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { altKey: true });
|
||||
expect(currentFrame.value).toBe(null);
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { ctrlKey: true });
|
||||
expect(currentFrame.value).toBe(null);
|
||||
});
|
||||
|
||||
it("a plain click still just selects the frame", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click");
|
||||
expect(currentFrame.value).toBe(2);
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("base takes no modifiers — it has no seed and nothing to bypass", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-base"]').trigger("click", { altKey: true });
|
||||
await w.find('[data-test="loop-frame-base"]').trigger("click", { ctrlKey: true });
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks locked chips so the state is readable without the modal", () => {
|
||||
const w = fw({ seed_locks: { "1": 123 } });
|
||||
expect(w.find('[data-test="loop-frame-2"]').classes()).toContain("wp-fchips__chip--locked");
|
||||
expect(w.find('[data-test="loop-frame-1"]').classes()).not.toContain("wp-fchips__chip--locked");
|
||||
});
|
||||
|
||||
it("a frame can be both locked and bypassed", () => {
|
||||
const w = fw({ seed_locks: { "1": 123 }, bypass_frames: [1] });
|
||||
const chip = w.find('[data-test="loop-frame-2"]');
|
||||
expect(chip.classes()).toContain("wp-fchips__chip--locked");
|
||||
expect(chip.classes()).toContain("wp-fchips__chip--bypassed");
|
||||
});
|
||||
|
||||
it("ignores non-numeric lock keys rather than rendering a NaN state", () => {
|
||||
const w = fw({ seed_locks: { junk: 1, "1": 123 } as Record<string, number> });
|
||||
expect(w.find('[data-test="loop-frame-2"]').classes()).toContain("wp-fchips__chip--locked");
|
||||
});
|
||||
|
||||
it("advertises the combos in the widget, not just in tooltips", () => {
|
||||
const w = fw();
|
||||
expect(w.find('[data-test="loop-frames-hint"]').text()).toMatch(/alt-click locks/i);
|
||||
expect(w.find('[data-test="loop-frames-hint"]').text()).toMatch(/alt|option/i);
|
||||
});
|
||||
|
||||
it("the tooltip names the action the next click will take", () => {
|
||||
const w = fw({ seed_locks: { "1": 123 }, bypass_frames: [1] });
|
||||
const t = w.find('[data-test="loop-frame-2"]').attributes("title")!;
|
||||
expect(t).toMatch(/unlock/i);
|
||||
expect(t).toMatch(/re-enable/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,13 @@
|
||||
* host glue (`src/widgets/context_loop.ts`) serializes to JSON and
|
||||
* pushes via `host.setValue` so ComfyUI's widget value matches.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import type { ContextLoopConfig, LoopStrategy } from "./types";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
import { deriveLoopSeeds } from "../shared/seed-derive";
|
||||
import { pushToast } from "../shared/toast-store";
|
||||
import { currentFrame, setFrame } from "./frame-cursor";
|
||||
import FrameChips from "../shared/FrameChips.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -21,12 +26,24 @@ const props = withDefaults(
|
||||
/** Litegraph mode: 0 = ALWAYS (live), 2 = NEVER (muted), 4 = BYPASS.
|
||||
* Drives the dim overlay so canvas-side mute/bypass reads visually. */
|
||||
nodeMode?: number;
|
||||
/** Base seed forwarded from the host ComfyUI INT widget. */
|
||||
baseSeed?: number;
|
||||
/** Iteration count forwarded from the host ComfyUI INT widget. */
|
||||
count?: number;
|
||||
/** The seed each frame used on the PREVIOUS run, captured from the node's
|
||||
* executed `loop_seeds` UI payload by the host glue. Drives the seed
|
||||
* modal's per-frame "lock previous" button. Null until a run lands. */
|
||||
previousSeeds?: number[] | null;
|
||||
}>(),
|
||||
{ nodeMode: 0 },
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1, previousSeeds: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [next: ContextLoopConfig] }>();
|
||||
|
||||
/** Frame-grid collapse. Owned by the widget glue so it can persist to
|
||||
* `node.properties` and survive a workflow save. */
|
||||
const chipsCollapsed = defineModel<boolean>("chipsCollapsed", { default: false });
|
||||
|
||||
const STRATEGIES: { id: LoopStrategy; label: string; hint: string }[] = [
|
||||
{ id: "hash_index", label: "hash", hint: "Independent per-iteration; recommended for varied results." },
|
||||
{ id: "sequential", label: "sequential", hint: "base, base+1, base+2, … Predictable diffs." },
|
||||
@@ -46,6 +63,81 @@ const OVERRIDE_SEED_TOOLTIP =
|
||||
const isMuted = computed<boolean>(() => props.nodeMode === 2);
|
||||
const isBypassed = computed<boolean>(() => props.nodeMode === 4);
|
||||
|
||||
const seedsOpen = ref(false);
|
||||
const lockedCount = computed(() => Object.keys(props.modelValue.seed_locks ?? {}).length);
|
||||
|
||||
function onSeedLocks(next: Record<string, number>): void {
|
||||
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
|
||||
}
|
||||
|
||||
const bypassedCount = computed(() => (props.modelValue.bypass_frames ?? []).length);
|
||||
/** 0-based bypassed frame indices — drives the dashed border on edit-frame
|
||||
* chips so bypassed frames read at a glance without opening the modal. */
|
||||
const bypassSet = computed(() => new Set(props.modelValue.bypass_frames ?? []));
|
||||
function onBypassFrames(next: number[]): void {
|
||||
emit("update:modelValue", { ...props.modelValue, bypass_frames: next });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Modifier-click on a frame chip — lock / bypass without the modal.
|
||||
*
|
||||
* Both states were already VISIBLE on the chips but only CHANGEABLE two clicks
|
||||
* away, inside a modal that covers the node you are reading. Toggling a couple
|
||||
* of frames is the common case; the modal earns its place for the bulk
|
||||
* operations (lock all, paste a series, out-of-range locks) and keeps them.
|
||||
*
|
||||
* Modifier choice: Alt-click locks a seed, Ctrl-click bypasses — the reflex
|
||||
* pairing users reach for. On macOS Ctrl-click IS a right-click, so Cmd stands
|
||||
* in for bypass there (see FrameChips `isBypassChord`); Alt/Option survives the
|
||||
* trip on every platform.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** 0-based locked frame indices. Keys are strings in the config; anything
|
||||
* non-numeric is ignored rather than becoming `NaN` in the set. */
|
||||
const lockSet = computed(() => {
|
||||
const out = new Set<number>();
|
||||
for (const k of Object.keys(props.modelValue.seed_locks ?? {})) {
|
||||
const n = Number(k);
|
||||
if (Number.isInteger(n)) out.add(n);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
/** The seed series this loop WOULD produce — the same values "Lock all" in
|
||||
* the modal writes, so a chip-lock and a modal-lock agree. */
|
||||
const derivedSeeds = computed(() =>
|
||||
deriveLoopSeeds(props.baseSeed, Math.max(1, props.count ?? 1), props.modelValue.strategy),
|
||||
);
|
||||
|
||||
/** In-range frames that are still running. Bypassing the last one would leave
|
||||
* the loop with nothing to execute, so the modal forbids it and so do we. */
|
||||
const activeFrameCount = computed(() => {
|
||||
let n = 0;
|
||||
for (let i = 0; i < Math.max(1, props.count ?? 1); i++) if (!bypassSet.value.has(i)) n++;
|
||||
return n;
|
||||
});
|
||||
|
||||
function toggleFrameLock(i: number): void {
|
||||
const next = { ...(props.modelValue.seed_locks ?? {}) };
|
||||
if (lockSet.value.has(i)) delete next[String(i)];
|
||||
else next[String(i)] = derivedSeeds.value[i] ?? props.baseSeed;
|
||||
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
|
||||
}
|
||||
|
||||
function toggleFrameBypass(i: number): void {
|
||||
const on = bypassSet.value.has(i);
|
||||
if (!on && activeFrameCount.value <= 1) {
|
||||
// Silence here would read as a broken click, and the reason is not
|
||||
// guessable from the chip.
|
||||
pushToast("A loop needs at least one running frame.", { severity: "warning" });
|
||||
return;
|
||||
}
|
||||
const next = new Set(bypassSet.value);
|
||||
if (on) next.delete(i);
|
||||
else next.add(i);
|
||||
onBypassFrames([...next].sort((a, b) => a - b));
|
||||
}
|
||||
|
||||
function pickStrategy(s: LoopStrategy): void {
|
||||
if (props.modelValue.strategy === s) return;
|
||||
emit("update:modelValue", { ...props.modelValue, strategy: s });
|
||||
@@ -108,6 +200,32 @@ function toggleTotalInternal(): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="wp-loop__seedbtn" data-test="loop-seeds-btn" @click="seedsOpen = true">
|
||||
<span class="wp-loop__seedbtn-ico"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="20" cy="18" r="1.4" fill="currentColor" stroke="none" /></svg></span>
|
||||
Per-iteration seeds
|
||||
<span class="wp-loop__seedbtn-fill" />
|
||||
<span v-if="lockedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-badge">{{ lockedCount }} locked</span>
|
||||
<span v-if="bypassedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-bypass-badge">{{ bypassedCount }} bypassed</span>
|
||||
<svg class="wp-loop__seedbtn-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M9 6l6 6-6 6" /></svg>
|
||||
</button>
|
||||
|
||||
<!-- `base` is not a frame: it has no seed to lock and nothing to bypass,
|
||||
so it takes no modifiers. -->
|
||||
<FrameChips
|
||||
label="edit frame"
|
||||
:count="Math.max(1, count ?? 1)"
|
||||
:locked="[...lockSet]"
|
||||
:bypassed="[...bypassSet]"
|
||||
v-model:collapsed="chipsCollapsed"
|
||||
:current="currentFrame"
|
||||
selectable
|
||||
show-base
|
||||
bypass-interactive
|
||||
@select="setFrame"
|
||||
@toggle-lock="toggleFrameLock"
|
||||
@toggle-bypass="toggleFrameBypass"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="wp-loop__row"
|
||||
:title="OVERRIDE_SEED_TOOLTIP"
|
||||
@@ -169,7 +287,7 @@ function toggleTotalInternal(): void {
|
||||
</div>
|
||||
|
||||
<div class="wp-loop__row">
|
||||
<span class="wp-loop__row-label">bypass loop</span>
|
||||
<span class="wp-loop__row-label">Bypass loop</span>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-loop__switch"
|
||||
@@ -181,6 +299,13 @@ function toggleTotalInternal(): void {
|
||||
<span class="wp-loop__switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SeedListModal v-if="seedsOpen" :node-name="'WP Context Loop'" :base-seed="baseSeed"
|
||||
:count="count" :strategy="modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
|
||||
:previous-seeds="previousSeeds"
|
||||
:bypass-frames="modelValue.bypass_frames ?? []"
|
||||
:override-hint="!modelValue.override_seed ? 'These seeds apply only when Override Context seed is on.' : ''"
|
||||
@update:seed-locks="onSeedLocks" @update:bypass-frames="onBypassFrames" @close="seedsOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -204,9 +329,11 @@ function toggleTotalInternal(): void {
|
||||
}
|
||||
|
||||
.wp-loop__chips { display: flex; gap: 4px; }
|
||||
/* Strategy chips only — the per-frame grid now lives in FrameChips.vue. */
|
||||
.wp-loop__chip {
|
||||
flex: 1;
|
||||
padding: 4px 6px;
|
||||
text-align: center;
|
||||
background: var(--wp-bg-deep, var(--wp-bg, #0e1015));
|
||||
border: 1px solid var(--wp-border, #353841);
|
||||
border-radius: 3px;
|
||||
@@ -289,8 +416,8 @@ function toggleTotalInternal(): void {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: 2px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
@@ -306,8 +433,15 @@ function toggleTotalInternal(): void {
|
||||
border-color: var(--wp-accent, #c4b5fd);
|
||||
color: var(--wp-accent, #c4b5fd);
|
||||
}
|
||||
.wp-loop__pi-btn .pi { font-size: 11px; }
|
||||
.wp-loop__pi-btn-label { font: 600 12px var(--wp-font-mono, monospace); line-height: 1; }
|
||||
.wp-loop__pi-btn .pi { font-size: 9px; }
|
||||
.wp-loop__pi-btn-label { font: 600 10px var(--wp-font-mono, monospace); line-height: 1; }
|
||||
|
||||
.wp-loop__seedbtn { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 9px; background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); border-radius: 3px; color: var(--wp-text-muted, var(--wp-text2)); font: 600 10.5px var(--wp-font-sans); cursor: pointer; }
|
||||
.wp-loop__seedbtn:hover { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.wp-loop__seedbtn-ico { color: var(--wp-accent); display: flex; }
|
||||
.wp-loop__seedbtn-fill { flex: 1; }
|
||||
.wp-loop__seedbtn-badge { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 6px; border-radius: 3px; color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.14); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
|
||||
.wp-loop__seedbtn-chev { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
|
||||
/* Bypass-on visually dims the strategy/override/var rows since the loop
|
||||
* is effectively off. The bypass row itself stays bright. */
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { currentFrame, setFrame } from "./frame-cursor";
|
||||
|
||||
describe("frame-cursor", () => {
|
||||
beforeEach(() => { currentFrame.value = null; });
|
||||
it("defaults to null (base)", () => { expect(currentFrame.value).toBe(null); });
|
||||
it("setFrame updates the shared ref", () => {
|
||||
setFrame(2);
|
||||
expect(currentFrame.value).toBe(2);
|
||||
setFrame(null);
|
||||
expect(currentFrame.value).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
// Module-scoped reactive singleton: the "current editing frame" cursor, shared
|
||||
// across every ContextWidget + ContextLoopWidget Vue app on the page. Each node
|
||||
// mounts its own app, so provide/inject can't span them — a shared module import
|
||||
// is the cross-app channel (same pattern as context/drag-store.ts). `null` = base
|
||||
// (no frame). Session-only — never serialized into the workflow.
|
||||
export const currentFrame = ref<number | null>(null);
|
||||
|
||||
export function setFrame(frame: number | null): void {
|
||||
currentFrame.value = frame;
|
||||
}
|
||||
@@ -30,6 +30,8 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: true,
|
||||
iteration_internal: false,
|
||||
total_internal: false,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -42,6 +44,8 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: false,
|
||||
iteration_internal: true,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -65,8 +69,45 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: true,
|
||||
iteration_internal: false,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
const round = parseContextLoopConfig(serializeContextLoopConfig(cfg));
|
||||
expect(round).toEqual(cfg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextLoopConfig seed_locks", () => {
|
||||
it("defaults seed_locks to {}", () => {
|
||||
expect(emptyContextLoopConfig().seed_locks).toEqual({});
|
||||
});
|
||||
it("parses seed_locks", () => {
|
||||
expect(parseContextLoopConfig(JSON.stringify({ seed_locks: { "1": 999 } })).seed_locks).toEqual({ "1": 999 });
|
||||
});
|
||||
it("drops non-numeric lock values", () => {
|
||||
expect(parseContextLoopConfig(JSON.stringify({ seed_locks: { "1": "x", "2": 5 } })).seed_locks).toEqual({ "2": 5 });
|
||||
});
|
||||
it("round-trips through serialize", () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), seed_locks: { "0": 7 } };
|
||||
expect(parseContextLoopConfig(serializeContextLoopConfig(cfg)).seed_locks).toEqual({ "0": 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bypass_frames parse", () => {
|
||||
it("defaults to empty", () => {
|
||||
expect(emptyContextLoopConfig().bypass_frames).toEqual([]);
|
||||
expect(parseContextLoopConfig("{}").bypass_frames).toEqual([]);
|
||||
});
|
||||
it("dedups + sorts, keeps out-of-range, drops junk + negatives", () => {
|
||||
expect(parseContextLoopConfig('{"bypass_frames":[3,1,3,0]}').bypass_frames).toEqual([0, 1, 3]);
|
||||
expect(parseContextLoopConfig('{"bypass_frames":[2,-1,"x",2,999]}').bypass_frames).toEqual([2, 999]);
|
||||
});
|
||||
it("drops coercible non-numbers, not coerce them (Python parity)", () => {
|
||||
// true/"3"/null would coerce to 1/3/0 under Number() — must be dropped.
|
||||
expect(parseContextLoopConfig('{"bypass_frames":[true,"3",null,2]}').bypass_frames).toEqual([2]);
|
||||
});
|
||||
it("malformed collapses to empty", () => {
|
||||
expect(parseContextLoopConfig('{"bypass_frames":"nope"}').bypass_frames).toEqual([]);
|
||||
expect(parseContextLoopConfig("not json").bypass_frames).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,13 @@ export interface ContextLoopConfig {
|
||||
iteration_internal: boolean;
|
||||
/** Same idea for `$<iteration_var_name>_total`. */
|
||||
total_internal: boolean;
|
||||
/** 0-based iteration index (stringified) -> pinned seed. Unlocked
|
||||
* indices re-derive from base+strategy. Empty by default. */
|
||||
seed_locks: Record<string, number>;
|
||||
/** 0-based iteration indices to bypass (skip generation + overrides).
|
||||
* Sorted, deduped, non-negative. Out-of-range (>= count) entries are
|
||||
* kept and re-apply if count grows. Empty by default. */
|
||||
bypass_frames: number[];
|
||||
}
|
||||
|
||||
const STRATEGIES = new Set<LoopStrategy>(["sequential", "hash_index", "prime_stride"]);
|
||||
@@ -32,6 +39,8 @@ export function emptyContextLoopConfig(): ContextLoopConfig {
|
||||
bypass: false,
|
||||
iteration_internal: true,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +69,25 @@ export function parseContextLoopConfig(raw: string | null | undefined): ContextL
|
||||
if (typeof obj.bypass === "boolean") out.bypass = obj.bypass;
|
||||
if (typeof obj.iteration_internal === "boolean") out.iteration_internal = obj.iteration_internal;
|
||||
if (typeof obj.total_internal === "boolean") out.total_internal = obj.total_internal;
|
||||
if (obj.seed_locks && typeof obj.seed_locks === "object" && !Array.isArray(obj.seed_locks)) {
|
||||
const locks: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(obj.seed_locks as Record<string, unknown>)) {
|
||||
if (typeof v === "number" && Number.isFinite(v)) locks[k] = v;
|
||||
}
|
||||
out.seed_locks = locks;
|
||||
}
|
||||
if (Array.isArray(obj.bypass_frames)) {
|
||||
const frames = new Set<number>();
|
||||
for (const x of obj.bypass_frames) {
|
||||
// Type-guard before accepting — do NOT coerce. Mirrors the Python
|
||||
// `_parse_config` (which rejects non-int via isinstance), so both
|
||||
// parsers degrade corrupt widget JSON identically (`true`, `"3"`,
|
||||
// `null` are dropped on both sides, not coerced to 1/3/0).
|
||||
if (typeof x !== "number" || !Number.isInteger(x) || x < 0) continue;
|
||||
frames.add(x);
|
||||
}
|
||||
out.bypass_frames = [...frames].sort((a, b) => a - b);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
import ConfirmDialog from "../shared/ConfirmDialog.vue";
|
||||
import { api } from "../../manager/api/client";
|
||||
@@ -165,14 +165,15 @@ function onKeydown(ev: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
||||
// Keys arrive from ModalShell (@keydown below) rather than window: the modal
|
||||
// shield stops inside-modal keys at the overlay so they cannot reach ComfyUI's
|
||||
// global shortcuts, so a window listener here would never fire.
|
||||
|
||||
const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalShell :visible="visible" @close="emit('close')">
|
||||
<ModalShell :visible="visible" @close="emit('close')" @keydown="onKeydown">
|
||||
<div class="wp-bp" role="dialog">
|
||||
<header class="wp-bp__head">
|
||||
<div class="wp-bp__head-text">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { flushPromises, mount } from "@vue/test-utils";
|
||||
import ContextWidget from "./ContextWidget.vue";
|
||||
import { baseCodename } from "../../extension/node-codename";
|
||||
import { _resetForTests as resetDriftStore } from "./drift-store";
|
||||
import { currentFrame } from "../context-loop/frame-cursor";
|
||||
|
||||
beforeEach(() => {
|
||||
// Phase 5.5.4 — the picker now fetches the library on mount. Stub
|
||||
@@ -207,6 +208,75 @@ describe("ContextWidget drift dot", () => {
|
||||
expect(wrapper.find(".wp-mod-dot--missing").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("shows drift for an empty-hash module whose uuid IS in the live library", async () => {
|
||||
// Repro: a workflow whose embedded module carries payload_hash "" (a
|
||||
// malformed/legacy/builder-emitted copy) but whose uuid exists in the
|
||||
// library. It must NOT be treated as inline/verdict-less — surface it as
|
||||
// drift so the bulk "refresh drifted" button can relink it.
|
||||
resetDriftStore();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { bbbbbbbb: { type: "wildcard", payload_hash: "h-LIVE" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
);
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "bbbbbbbb", type: "wildcard", enabled: true,
|
||||
meta: { name: "wc", description: "", tags: [] }, entries: [],
|
||||
payload: { options: [] }, payload_hash: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 98, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find(".wp-mod-dot--drift").exists()).toBe(true);
|
||||
});
|
||||
expect(wrapper.find(".wp-mod-dot--missing").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("leaves a genuinely inline module (empty hash, uuid NOT in library) verdict-less", async () => {
|
||||
resetDriftStore();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { somethingelse: { type: "wildcard", payload_hash: "h-LIVE" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
);
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "cccccccc", type: "wildcard", enabled: true,
|
||||
meta: { name: "inline", description: "", tags: [] }, entries: [],
|
||||
payload: { options: [] }, payload_hash: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 97, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
// Give the poll a beat to land, then assert no drift/missing badges.
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
expect(wrapper.find(".wp-mod-dot--drift").exists()).toBe(false);
|
||||
expect(wrapper.find(".wp-mod-dot--missing").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextWidget bulk refresh", () => {
|
||||
@@ -648,6 +718,105 @@ describe("ContextWidget bundle ops via ctxmenu", () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("Reset REFUSES (keeps children) when the instance isn't library-linked", async () => {
|
||||
resetDriftStore();
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 })));
|
||||
const bundleUid = "buidEMPTY001";
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "dddddddd", type: "wildcard", enabled: true,
|
||||
meta: { name: "LocallyEdited" }, entries: [],
|
||||
payload: { var_binding: "v", options: [{ id: "o2", value: "y", weight: 2 }] },
|
||||
payload_hash: "h-edited", bundle_origin: bundleUid,
|
||||
},
|
||||
],
|
||||
bundles: [
|
||||
{
|
||||
_uid: bundleUid, library_id: "", start_idx: 0, end_idx: 0,
|
||||
enabled: true, collapsed: false, inserted_at_hash: "", name: "Unlinked", color: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 305, initialJson, upstreamVars: [], onChange },
|
||||
});
|
||||
await flushPromises();
|
||||
onChange.mockClear();
|
||||
|
||||
const header = document.querySelector(`[data-bundle-uid="${bundleUid}"][data-bundle-header]`);
|
||||
expect(header).toBeTruthy();
|
||||
header!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const reset = items.find((el) => el.textContent?.includes("Reset to library snapshot"));
|
||||
expect(reset).toBeTruthy();
|
||||
reset!.click();
|
||||
await flushPromises();
|
||||
|
||||
// Refused — the live child is untouched, no destructive empty commit.
|
||||
expect(wrapper.find('[data-module-id="dddddddd"]').exists()).toBe(true);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("Reset REFUSES (keeps children) when the library snapshot comes back empty", async () => {
|
||||
resetDriftStore();
|
||||
const fetchSpy = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (typeof url === "string" && url.match(/\/wp\/api\/bundles\/bn_empty$/) && (!init || init.method === "GET")) {
|
||||
return new Response(JSON.stringify({
|
||||
id: "bn_empty", name: "EmptySnap", description: "", color: null,
|
||||
category_id: null, tags: [], is_favorite: false,
|
||||
children: [], payload_hash: "ph-empty", version: 1, created_at: "", updated_at: "",
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const bundleUid = "buidEMPTY002";
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "dddddddd", type: "wildcard", enabled: true,
|
||||
meta: { name: "LocallyEdited" }, entries: [],
|
||||
payload: { var_binding: "v", options: [{ id: "o2", value: "y", weight: 2 }] },
|
||||
payload_hash: "h-edited", bundle_origin: bundleUid,
|
||||
},
|
||||
],
|
||||
bundles: [
|
||||
{
|
||||
_uid: bundleUid, library_id: "bn_empty", start_idx: 0, end_idx: 0,
|
||||
enabled: true, collapsed: false, inserted_at_hash: "ph-old", name: "EmptySnap", color: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 306, initialJson, upstreamVars: [], onChange },
|
||||
});
|
||||
await flushPromises();
|
||||
onChange.mockClear();
|
||||
|
||||
const header = document.querySelector(`[data-bundle-uid="${bundleUid}"][data-bundle-header]`);
|
||||
expect(header).toBeTruthy();
|
||||
header!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const reset = items.find((el) => el.textContent?.includes("Reset to library snapshot"));
|
||||
expect(reset).toBeTruthy();
|
||||
reset!.click();
|
||||
await flushPromises();
|
||||
|
||||
// The empty snapshot must NOT wipe the live child.
|
||||
expect(wrapper.find('[data-module-id="dddddddd"]').exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("Reset re-adds children removed since insert (bundle end_idx grew via reconcile)", async () => {
|
||||
resetDriftStore();
|
||||
// Library has TWO children; in-graph bundle currently has ONE
|
||||
@@ -728,6 +897,124 @@ describe("ContextWidget bundle ops via ctxmenu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextWidget Hold toggle via ctxmenu", () => {
|
||||
beforeEach(() => {
|
||||
// Hold is base-level; pin the shared frame cursor to base so the
|
||||
// menu item isn't disabled by a leftover edit-frame from another test.
|
||||
currentFrame.value = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
currentFrame.value = null;
|
||||
});
|
||||
|
||||
it("Hold across run stamps seed_scope=hold; reopening offers Release hold which flips back to vary", async () => {
|
||||
resetDriftStore();
|
||||
const onChange = vi.fn();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "aaaaaaaa", type: "wildcard", enabled: true,
|
||||
meta: { name: "Hair" }, entries: [],
|
||||
payload: { options: [], var_binding: "hair" }, payload_hash: "h-A",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 410, initialJson, upstreamVars: [], onChange },
|
||||
});
|
||||
await flushPromises();
|
||||
onChange.mockClear();
|
||||
|
||||
// Un-held module → item reads "Hold across run" and is enabled.
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
let items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const hold = items.find((el) => el.textContent?.includes("Hold across run"));
|
||||
expect(hold, "Hold item should appear for a wildcard row").toBeTruthy();
|
||||
expect(hold!.classList.contains("wp-ctxmenu__item--disabled")).toBe(false);
|
||||
hold!.click();
|
||||
await flushPromises();
|
||||
|
||||
const afterHold = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string;
|
||||
expect(JSON.parse(afterHold).modules[0].instance.seed_scope).toBe("hold");
|
||||
|
||||
// Held module → label swaps to "Release hold"; clicking it varies again.
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const release = items.find((el) => el.textContent?.includes("Release hold"));
|
||||
expect(release, "Held row should offer Release hold").toBeTruthy();
|
||||
release!.click();
|
||||
await flushPromises();
|
||||
|
||||
const afterRelease = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string;
|
||||
expect(JSON.parse(afterRelease).modules[0].instance.seed_scope).toBe("vary");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("disables the Hold item when a loop edit-frame is active (hold is base-only)", async () => {
|
||||
resetDriftStore();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "aaaaaaaa", type: "wildcard", enabled: true,
|
||||
meta: { name: "Hair" }, entries: [],
|
||||
payload: { options: [], var_binding: "hair" }, payload_hash: "h-A",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 412, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
await flushPromises();
|
||||
currentFrame.value = 0; // user is editing frame #0 of a loop
|
||||
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const hold = items.find((el) => el.textContent?.includes("Hold across run"));
|
||||
expect(hold, "Hold item still present while a frame is active").toBeTruthy();
|
||||
expect(hold!.classList.contains("wp-ctxmenu__item--disabled")).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("omits the Hold item for a constraint row (seed_scope not honored)", async () => {
|
||||
resetDriftStore();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{ id: "aaaaaaaa", type: "wildcard", enabled: true, meta: { name: "Hair" }, entries: [], payload: { options: [], var_binding: "hair" } },
|
||||
{ id: "bbbbbbbb", type: "wildcard", enabled: true, meta: { name: "Outfit" }, entries: [], payload: { options: [], var_binding: "outfit" } },
|
||||
{
|
||||
id: "cccccccc", type: "constraint", enabled: true,
|
||||
meta: { name: "Hair × Outfit" }, entries: [],
|
||||
payload: {
|
||||
source_wildcard_id: "aaaaaaaa", target_wildcard_id: "bbbbbbbb",
|
||||
matrix: { blonde: { casual: { mode: "allow", factor: 1 } } }, exceptions: [],
|
||||
}, payload_hash: "h",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 411, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-module-id="cccccccc"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
expect(items.find((el) => el.textContent?.includes("Hold across run"))).toBeUndefined();
|
||||
expect(items.find((el) => el.textContent?.includes("Release hold"))).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextWidget summaryFor — constraint", () => {
|
||||
it("renders $src → $tgt · NxM with sibling lookup", async () => {
|
||||
resetDriftStore();
|
||||
@@ -947,6 +1234,64 @@ function mountWithModules(stubs: StubModule[]) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── D3a: library re-link (onPushSaved path) ──────────────────────────────────
|
||||
|
||||
describe("ContextWidget module re-link", () => {
|
||||
it("re-links a detached row + follows a sibling constraint's source_wildcard_id", async () => {
|
||||
const onChange = vi.fn();
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: {
|
||||
nodeId: 9100,
|
||||
initialJson: JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "dead0001", type: "wildcard", enabled: true,
|
||||
meta: { name: "hair", description: "", tags: [] }, entries: [],
|
||||
payload: { options: [{ id: "o1", value: "red" }] }, payload_hash: "HASH_A",
|
||||
},
|
||||
{
|
||||
id: "c1", type: "constraint", enabled: true,
|
||||
meta: { name: "cn", description: "", tags: [] }, entries: [],
|
||||
payload: { source_wildcard_id: "dead0001", target_wildcard_id: "w2", matrix: {} },
|
||||
payload_hash: "HC",
|
||||
},
|
||||
],
|
||||
}),
|
||||
upstreamVars: [],
|
||||
onChange,
|
||||
},
|
||||
});
|
||||
// Let the initial parse settle (its suppressWatch cycle) so the relink
|
||||
// change below isn't coalesced into the swallowed first watcher tick.
|
||||
await flushPromises();
|
||||
onChange.mockClear();
|
||||
|
||||
// Two PushToLibraryModal instances exist: ModuleEditModal's (bound to
|
||||
// onPushToLibrarySaved, draft=null here) and ContextWidget's own (bound to
|
||||
// onPushSaved, the right-click path). The latter is last in DFS order.
|
||||
const ptls = wrapper.findAllComponents({ name: "PushToLibraryModal" });
|
||||
expect(ptls.length).toBeGreaterThan(0);
|
||||
const ptl = ptls[ptls.length - 1];
|
||||
ptl.vm.$emit("saved", {
|
||||
mode: "relink", id: "live0001", origId: "dead0001",
|
||||
payload_hash: "HASH_A", name: "hair", bundles_updated: [],
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const calls = onChange.mock.calls;
|
||||
const lastJson = calls[calls.length - 1]?.[0] as string;
|
||||
const parsed = JSON.parse(lastJson) as {
|
||||
modules: Array<{ id: string; type: string; payload: Record<string, unknown> }>;
|
||||
};
|
||||
const wc = parsed.modules.find((m) => m.type === "wildcard");
|
||||
const con = parsed.modules.find((m) => m.type === "constraint");
|
||||
expect(wc?.id).toBe("live0001");
|
||||
expect(con?.payload.source_wildcard_id).toBe("live0001");
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sibling badge (Phase B: lives in summary line, not header) ────────────
|
||||
//
|
||||
// Phase A shipped the badge in the header always-visible. Phase B
|
||||
@@ -1150,6 +1495,56 @@ describe("ContextWidget status badges", () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("an UNLINKED bundle reads as missing, not modified", async () => {
|
||||
// A bundle with no `library_id` could never be MISSING (that check
|
||||
// early-returned false without one), so its only badge was MODIFIED —
|
||||
// diffing its children against a snapshot baseline from a library entry
|
||||
// that isn't there. "Modified against what?" had no answer.
|
||||
resetDriftStore();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: {} }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
nodeId: 815,
|
||||
initialJson: JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{ id: "bbbbbbbb", type: "wildcard", enabled: true, meta: { name: "child" }, entries: [], payload: {} },
|
||||
],
|
||||
bundles: [{
|
||||
_uid: "bu1",
|
||||
// No library_id — the unlinked shape.
|
||||
library_id: "",
|
||||
name: "Pose Library",
|
||||
start_idx: 0,
|
||||
end_idx: 0,
|
||||
enabled: true,
|
||||
// A stale baseline that would otherwise light up MOD.
|
||||
snapshot_fingerprint: "v2:deadbeef",
|
||||
}],
|
||||
}),
|
||||
upstreamVars: [],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find(".wp-mod-badge--missing").exists()).toBe(true);
|
||||
});
|
||||
// MOD is meaningless with no insert to have diverged from.
|
||||
expect(wrapper.find(".wp-mod-badge--modified").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("renders 'missing' text badge when uuid is not in library", async () => {
|
||||
resetDriftStore();
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -11,6 +11,7 @@ import { type ResolvedValue } from "../../widgets/richTokenize";
|
||||
import { scanConflicts, labelFor as conflictLabelFor, shortConflictLabel, type Conflict } from "../../extension/conflicts";
|
||||
import type { ChainModule, PairingBadge, RowPairings } from "../../extension/constraint-pairs";
|
||||
import { baseCodename } from "../../extension/node-codename";
|
||||
import { derivationTargets } from "../../extension/derivation-conditions";
|
||||
import {
|
||||
getCollapseMode,
|
||||
getCollapsedByDefault,
|
||||
@@ -28,7 +29,14 @@ import BundleFrame from "./bundles/BundleFrame.vue";
|
||||
import { BundleFrameCtxKey, type BundleFrameCtx } from "./bundles/bundle-frame-ctx";
|
||||
import ModuleRow from "./ModuleRow.vue";
|
||||
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
|
||||
import {
|
||||
CONTEXT_POOLS_KEY,
|
||||
FOREIGN_POOL_LOOKUP_KEY,
|
||||
buildContextPools,
|
||||
} from "../../extension/context-pools";
|
||||
import { LockSeedKey } from "./lock-seed-ctx";
|
||||
import { buildBundleInsertion, type BundleLibraryEntry } from "./bundles/insert";
|
||||
import { isEnabled, type WildcardOption, type InstanceLike } from "./editors/wildcard/probability";
|
||||
import { buildLibraryChildrenWithIntegrity, toChildSnapshot } from "./bundles/save";
|
||||
import {
|
||||
cascadeRestoreForBundle,
|
||||
@@ -51,7 +59,8 @@ import {
|
||||
MOTION_FLIP_MS,
|
||||
MOTION_CURVE_FLIP,
|
||||
} from "./bundles/flip";
|
||||
import { api } from "../../manager/api/client";
|
||||
import { ApiError, api } from "../../manager/api/client";
|
||||
import type { VarProducerLike } from "../../manager/components/RefChip.vue";
|
||||
import { emptyBundleInstance, type BundleInstance } from "../../widgets/_shared";
|
||||
import ModuleEditModal from "./ModuleEditModal.vue";
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
@@ -73,7 +82,9 @@ import { dragState, queueHandoff, pendingHandoffs, takeHandoffsFor } from "./dra
|
||||
import { nextBindingSuffix } from "./duplicates/binding-suffix";
|
||||
import { pushToast } from "../shared/toast-store";
|
||||
import { kindIcon } from "../shared/kind-icons";
|
||||
import { KIND_TITLE } from "./editors/_shell";
|
||||
import { KIND_TITLE, FRAME_OVERRIDABLE_FIELDS } from "./editors/_shell";
|
||||
import { currentFrame } from "../context-loop/frame-cursor";
|
||||
import { withFrameInstance, diffInstance, setFrameOverride, clearFrameOverride, toggleFrameLock, toggleFrameEnabled, dropRedundantFrameLockNulls, effectiveEnabled as feEffectiveEnabled, frameEnableOverride as feFrameEnableOverride } from "./frame-overrides";
|
||||
import { varColorClass } from "../shared/var-color";
|
||||
import wpLogoSvg from "../shared/wp-logo.svg?raw";
|
||||
import {
|
||||
@@ -87,6 +98,7 @@ import {
|
||||
unsubscribe as unsubscribeDrift,
|
||||
} from "./drift-store";
|
||||
import { classifyOne, type CollisionState } from "../../manager/import-export/collision";
|
||||
import { applyRelink } from "../../manager/import-export/relink-apply";
|
||||
import {
|
||||
getBundleCollapsedByDefault,
|
||||
getConfirmDestructiveBundle,
|
||||
@@ -97,6 +109,16 @@ const props = withDefaults(defineProps<{
|
||||
nodeId: number;
|
||||
initialJson: string;
|
||||
upstreamVars: string[];
|
||||
/** `$var` → which module in which node writes it (last-write-wins).
|
||||
* Threaded to the edit modals so a var chip's hover card can name its
|
||||
* producer instead of only confirming the name exists. Optional for
|
||||
* headless mounts. */
|
||||
upstreamProducers?: Record<string, VarProducerLike>;
|
||||
/** uuid → labels of OTHER Context nodes that hold it as a wildcard. Lets a
|
||||
* nested-ref hover card explain WHY it fell back to the library: the pool
|
||||
* exists on the canvas, just not in this node, and a catalog never crosses
|
||||
* a node boundary. Absent in headless mounts (no graph to walk). */
|
||||
wildcardHomesReader?: (uuid: string) => string[];
|
||||
/** Resolved upstream-var snapshot — `$name → resolved string` map.
|
||||
* Drives the combine modal's live-preview pane so users see the
|
||||
* template with vars substituted (e.g. `red portrait` instead of
|
||||
@@ -150,6 +172,10 @@ const props = withDefaults(defineProps<{
|
||||
nodeMode?: number;
|
||||
/** Last-run seed reader keyed by module id. Null = user hasn't queued. */
|
||||
lastUsedSeedReader?: (moduleId?: string) => number | null;
|
||||
/** Per-frame seed reader — the seed THIS node rolls at iteration frame #k,
|
||||
* computed from the upstream Loop without a run. Drives the per-frame seed
|
||||
* lock so it pins the seed the module actually uses on that frame. */
|
||||
frameSeedReader?: (frame: number) => number | null;
|
||||
onChange: (json: string) => void;
|
||||
/** Called whenever the formula-computed minimum node width changes
|
||||
* (a conflict appears, a state badge gets attached). Mount glue
|
||||
@@ -459,7 +485,7 @@ function openPushToLibrary(idx: number): void {
|
||||
}
|
||||
|
||||
interface PushSaveResult {
|
||||
mode: "update" | "fork" | "reattach";
|
||||
mode: "update" | "fork" | "reattach" | "relink";
|
||||
id: string;
|
||||
payload_hash: string;
|
||||
bundles_updated: string[];
|
||||
@@ -503,6 +529,21 @@ function onPushSaved(result: PushSaveResult): void {
|
||||
};
|
||||
});
|
||||
commitModules(next);
|
||||
} else if (result.mode === "relink") {
|
||||
// Re-link: point the detached row(s) at an EXISTING content-identical
|
||||
// library uuid AND walk every module so sibling @{} refs + constraint
|
||||
// source/target follow the new uuid (applyRelink swaps id + payload_hash
|
||||
// on the matching row + remaps refs everywhere else).
|
||||
const remapped = applyRelink(
|
||||
value.value.modules as unknown as Record<string, unknown>[],
|
||||
{ oldId: result.origId, newId: result.id, newPayloadHash: result.payload_hash },
|
||||
) as unknown as ModuleEntry[];
|
||||
const next = remapped.map((m) =>
|
||||
m.id === result.id
|
||||
? { ...m, meta: { ...(m.meta ?? {}), library_name: result.name } }
|
||||
: m,
|
||||
);
|
||||
commitModules(next);
|
||||
}
|
||||
const bundlesNote =
|
||||
result.bundles_updated.length > 0
|
||||
@@ -511,6 +552,7 @@ function onPushSaved(result: PushSaveResult): void {
|
||||
let msg: string;
|
||||
if (result.mode === "fork") msg = `Saved as new library entry "${result.name}"`;
|
||||
else if (result.mode === "reattach") msg = `Re-attached "${result.name}" to library`;
|
||||
else if (result.mode === "relink") msg = `Re-linked "${result.name}" to library`;
|
||||
else msg = `Saved "${result.name}" to library${bundlesNote}`;
|
||||
pushToast(msg, { severity: "success" });
|
||||
pushOpen.value = false;
|
||||
@@ -625,7 +667,7 @@ async function pushBundleCascadeRestore(): Promise<{
|
||||
}
|
||||
|
||||
interface BundlePushSaveResult {
|
||||
mode: "update" | "fork" | "reattach";
|
||||
mode: "update" | "fork" | "reattach" | "relink";
|
||||
id: string;
|
||||
payload_hash: string;
|
||||
name: string;
|
||||
@@ -634,11 +676,12 @@ interface BundlePushSaveResult {
|
||||
}
|
||||
function onBundlePushSaved(result: BundlePushSaveResult): void {
|
||||
void forceRefreshHashes();
|
||||
if (result.mode === "update" || result.mode === "reattach") {
|
||||
if (result.mode === "update" || result.mode === "reattach" || result.mode === "relink") {
|
||||
// Update: refresh inserted_at_hash + snapshot fingerprint for every
|
||||
// BundleInstance pointing at this library entry — drift + MOD dots
|
||||
// clear instantly. Reattach: also rebind library_id from the dead
|
||||
// uuid to the freshly-created one.
|
||||
// uuid to the freshly-created one. Relink: same rebind, but onto an
|
||||
// EXISTING content-identical library bundle (no write happened).
|
||||
const nextBundles = (value.value.bundles ?? []).map((b) => {
|
||||
if (b.library_id !== result.origId) return b;
|
||||
const rebound: BundleInstance = {
|
||||
@@ -655,6 +698,7 @@ function onBundlePushSaved(result: BundlePushSaveResult): void {
|
||||
let msg: string;
|
||||
if (result.mode === "fork") msg = `Saved as new library bundle "${result.name}"`;
|
||||
else if (result.mode === "reattach") msg = `Re-attached "${result.name}" to library`;
|
||||
else if (result.mode === "relink") msg = `Re-linked "${result.name}" to library`;
|
||||
else msg = `Saved "${result.name}" to library`;
|
||||
if (result.cascade) {
|
||||
const parts: string[] = [];
|
||||
@@ -678,9 +722,11 @@ function onBundlePushClosed(): void {
|
||||
pushBundleTargetUid.value = null;
|
||||
pushBundleCascadeScan.value = null;
|
||||
}
|
||||
const editingModule = computed<ModuleEntry | null>(() =>
|
||||
editingIdx.value != null ? (value.value.modules[editingIdx.value] ?? null) : null,
|
||||
);
|
||||
const editingModule = computed<ModuleEntry | null>(() => {
|
||||
if (editingIdx.value == null) return null;
|
||||
const m = value.value.modules[editingIdx.value] ?? null;
|
||||
return m ? withFrameInstance(m, currentFrame.value) : null;
|
||||
});
|
||||
|
||||
/** Per-occurrence pair lookup for the currently-edited CARRIER module.
|
||||
* Map keys are the carrier's `via.optionIds` — wildcard option ids for a
|
||||
@@ -736,11 +782,82 @@ const resolvedForEditing = computed<Record<string, ResolvedValue>>(() => {
|
||||
// Combine + wildcard have no entries; without this kind-aware fallback the
|
||||
// modal's insert-var dropdown would render empty for chains where the only
|
||||
// vars come from binding-producer modules.
|
||||
const siblingNodeVars = computed<string[]>(() => {
|
||||
/**
|
||||
* Vars written by the OTHER modules in this same Context node, together with
|
||||
* who writes each one.
|
||||
*
|
||||
* `collectUpstreamProducers` walks the node chain and deliberately excludes
|
||||
* the point-of-view node, so it can only attribute vars produced further
|
||||
* upstream. In a graph where one Context node carries most of the modules —
|
||||
* which is the normal shape — that left nearly every `$var` in the
|
||||
* autocomplete with no writer at all, and the rows rendered bare while the
|
||||
* SPA's equivalent showed a full attribution line for the same names.
|
||||
*
|
||||
* Built in the same pass as the name list so the two cannot disagree about
|
||||
* which module owns a name.
|
||||
*/
|
||||
/** The `accepts` axes a wildcard payload declares, with the tags each rolls
|
||||
* from. `hueIndex` counts EVERY group so a colour matches the wildcard
|
||||
* editor's. Mirrors `acceptsAxesOf` in `manager/utils/library-suggestions`;
|
||||
* duplicated rather than imported because that module pulls the whole module
|
||||
* store into the canvas bundle, which the size gate will not carry. */
|
||||
function acceptsAxesOfPayload(
|
||||
payload: unknown,
|
||||
): { axis: string; tags: string[]; hueIndex: number }[] | undefined {
|
||||
const p = (payload ?? {}) as {
|
||||
tag_groups?: Record<string, string[]>;
|
||||
tag_group_kinds?: Record<string, string>;
|
||||
};
|
||||
if (!p.tag_group_kinds) return undefined;
|
||||
const out: { axis: string; tags: string[]; hueIndex: number }[] = [];
|
||||
Object.entries(p.tag_groups ?? {}).forEach(([axis, tags], hueIndex) => {
|
||||
if (p.tag_group_kinds?.[axis] === "accepts") {
|
||||
out.push({ axis, tags: tags ?? [], hueIndex });
|
||||
}
|
||||
});
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
const siblingVarInfo = computed<{
|
||||
names: string[];
|
||||
producers: Record<string, VarProducerLike>;
|
||||
}>(() => {
|
||||
const names = new Set<string>();
|
||||
const producers: Record<string, VarProducerLike> = {};
|
||||
/** Last module to write each name, so repeat writes from one module (a
|
||||
* derivation's branches) do not inflate the override count. */
|
||||
const lastWriter = new Map<string, string>();
|
||||
let owner: {
|
||||
kind: string; moduleName: string; moduleId: string;
|
||||
axes?: { axis: string; tags: string[]; hueIndex: number }[];
|
||||
} | null = null;
|
||||
function add(name: string | undefined | null): void {
|
||||
const trimmed = (name ?? "").replace(/^\$+/, "").trim();
|
||||
if (trimmed) names.add(trimmed);
|
||||
if (!trimmed) return;
|
||||
names.add(trimmed);
|
||||
if (!owner) return;
|
||||
// Later modules win, matching the engine's last-write-wins resolution.
|
||||
// The count tracks distinct MODULES, not write statements: a derivation
|
||||
// calls this once per branch and a fixed_values once per row, but at most
|
||||
// one branch fires and the module is a single writer either way.
|
||||
const prev = producers[trimmed];
|
||||
const sameWriter = lastWriter.get(trimmed) === owner.moduleId;
|
||||
lastWriter.set(trimmed, owner.moduleId);
|
||||
producers[trimmed] = {
|
||||
kind: owner.kind,
|
||||
moduleName: owner.moduleName,
|
||||
moduleId: owner.moduleId,
|
||||
// Same node, so there is no other node to name — saying which one it is
|
||||
// would be noise when every sibling gives the same answer.
|
||||
nodeLabel: "this node",
|
||||
shadowed: prev ? (sameWriter ? prev.shadowed : prev.shadowed + 1) : 0,
|
||||
// `accepts` axes, so `$var.AXIS` completion works for a variable written
|
||||
// in THIS node. Same-node writers replace the upstream entry wholesale
|
||||
// in `allVarProducers`, so axes attached only during the graph walk were
|
||||
// dropped for exactly the common case: the wildcard and the template
|
||||
// that reads it sitting in one Context.
|
||||
...(owner.axes ? { axes: owner.axes } : {}),
|
||||
};
|
||||
}
|
||||
const editingM = editingModule.value;
|
||||
const bundleEnabled = buildBundleEnabledMap(value.value.bundles);
|
||||
@@ -751,6 +868,15 @@ const siblingNodeVars = computed<string[]>(() => {
|
||||
// Defensive: also skip if same object (shouldn't happen but Vue
|
||||
// proxies can confuse identity equals).
|
||||
void editingM;
|
||||
owner = {
|
||||
kind: m.type,
|
||||
moduleName: m.meta?.name ?? m.type,
|
||||
moduleId: m.id,
|
||||
// Read from the module's own payload snapshot — the node runs what it
|
||||
// captured, so this is the truth for THIS node regardless of what the
|
||||
// library says now.
|
||||
...(m.type === "wildcard" ? { axes: acceptsAxesOfPayload(m.payload) } : {}),
|
||||
};
|
||||
for (const e of m.entries) add(e.variable_name);
|
||||
const inst = (m.instance ?? {}) as {
|
||||
variable_binding?: string | null;
|
||||
@@ -777,13 +903,49 @@ const siblingNodeVars = computed<string[]>(() => {
|
||||
for (const v of p.values ?? []) add(v.name);
|
||||
}
|
||||
} else if (m.type === "derivation") {
|
||||
for (const rule of p.rules ?? []) {
|
||||
for (const br of rule.branches ?? []) add(br.action?.target_var);
|
||||
add(rule.else?.action?.target_var);
|
||||
}
|
||||
for (const name of derivationTargets(p)) add(name);
|
||||
}
|
||||
}
|
||||
return [...names];
|
||||
owner = null;
|
||||
return { names: [...names], producers };
|
||||
});
|
||||
|
||||
const siblingNodeVars = computed<string[]>(() => siblingVarInfo.value.names);
|
||||
|
||||
/**
|
||||
* Every `$var` writer the editors can name: upstream nodes first, then this
|
||||
* node's own modules on top.
|
||||
*
|
||||
* Siblings override because they run later in the same chain position, which
|
||||
* is what the engine does too — so the row names the module whose value
|
||||
* actually survives.
|
||||
*
|
||||
* The override COUNT has to be carried across the seam. A plain spread
|
||||
* replaces the upstream entry outright, taking its `shadowed` 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 reported
|
||||
* "0" — no badge at all — because the sibling map only ever counted siblings
|
||||
* and started from zero. A sibling that displaces an upstream writer has
|
||||
* overridden that writer PLUS everything it had already overridden.
|
||||
*/
|
||||
const allVarProducers = computed<Record<string, VarProducerLike>>(() => {
|
||||
const upstream = props.upstreamProducers ?? {};
|
||||
const merged: Record<string, VarProducerLike> = { ...upstream };
|
||||
for (const [name, sib] of Object.entries(siblingVarInfo.value.producers)) {
|
||||
const up = upstream[name];
|
||||
// Same carry-forward as the graph walk: a same-node writer that declares
|
||||
// no axes must not erase the ones an upstream wildcard declared for this
|
||||
// binding — the engine keys `__wp_axes__` by binding, so the rolled axis
|
||||
// outlives a rebind.
|
||||
merged[name] = up
|
||||
? {
|
||||
...sib,
|
||||
...(sib.axes ?? up.axes ? { axes: sib.axes ?? up.axes } : {}),
|
||||
shadowed: sib.shadowed + up.shadowed + 1,
|
||||
}
|
||||
: sib;
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
|
||||
function clearDragHover() {
|
||||
@@ -1583,6 +1745,17 @@ async function duplicateBundle(uid: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Human message for a failed bundle-snapshot fetch. A 404 means the
|
||||
* library row is gone (deleted, or re-imported under a fresh uuid), which
|
||||
* the raw API text doesn't say — and re-linking, not retrying, is the fix.
|
||||
* Anything else falls back to the raw message. */
|
||||
function bundleResetErrorMessage(e: unknown, name: string): string {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
return `Can't reset "${name || "bundle"}" — its library bundle no longer exists. Re-link it first.`;
|
||||
}
|
||||
return `Reset failed: ${e instanceof Error ? e.message : String(e)}`;
|
||||
}
|
||||
|
||||
/** Reset bundle to library snapshot — re-fetch the library entry
|
||||
* + replace current children with the frozen blob. Drops any user
|
||||
* edits made to bundle children since insert. Confirms first since
|
||||
@@ -1591,6 +1764,17 @@ async function resetBundleToLibrary(uid: string): Promise<void> {
|
||||
const bundles = value.value.bundles ?? [];
|
||||
const target = bundles.find((b) => b._uid === uid);
|
||||
if (!target) return;
|
||||
// Guard: a detached/unlinked instance (empty library_id — e.g. a bundle
|
||||
// whose section was never linked, or whose library row was re-imported
|
||||
// under a new uuid) has nothing to fetch. Refuse instead of fetching an
|
||||
// empty snapshot and wiping the live children. Re-link is the recovery.
|
||||
if (!target.library_id) {
|
||||
pushToast(
|
||||
`Can't reset "${target.name || "bundle"}" — it isn't linked to a library bundle. Re-link it first.`,
|
||||
{ severity: "error" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Delta-undo capture: keep the old child refs (their _uids will be
|
||||
// discarded by the reset since the new library snapshot stamps
|
||||
// fresh ones, but the BundleInstance _uid we re-find by below
|
||||
@@ -1614,6 +1798,17 @@ async function resetBundleToLibrary(uid: string): Promise<void> {
|
||||
// local work.
|
||||
try {
|
||||
const entry = await api.bundles.get(target.library_id);
|
||||
// Guard: never wipe the live children with an empty snapshot. A valid
|
||||
// library bundle always resolves at least one child; an empty/missing
|
||||
// `children` means the fetch resolved to the wrong shape (broken link,
|
||||
// list wrapper, stale row). Abort the destructive replace + tell the user.
|
||||
if (!Array.isArray(entry.children) || entry.children.length === 0) {
|
||||
pushToast(
|
||||
`Can't reset "${target.name || "bundle"}" — the library snapshot came back empty (its link may be broken). Re-link the bundle instead.`,
|
||||
{ severity: "error" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const libEntry: BundleLibraryEntry = {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
@@ -1761,8 +1956,7 @@ async function resetBundleToLibrary(uid: string): Promise<void> {
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
pushToast(`Reset failed: ${msg}`, { severity: "error" });
|
||||
pushToast(bundleResetErrorMessage(e, target.name), { severity: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1781,6 +1975,18 @@ async function resetChildToBundleSnapshot(idx: number): Promise<void> {
|
||||
if (!bundle) return;
|
||||
const posInBundle = idx - bundle.start_idx;
|
||||
if (posInBundle < 0) return;
|
||||
// Guard: mirrors resetBundleToLibrary. An unlinked/detached BundleInstance
|
||||
// carries an empty `library_id`; fetching it builds `/wp/api/bundles/` whose
|
||||
// trailing slash normalizes to the collection route, so aiohttp reports
|
||||
// "No such API route: /wp/api/bundles" — a nonsense message for the user.
|
||||
// Refuse up front and name the real recovery.
|
||||
if (!bundle.library_id) {
|
||||
pushToast(
|
||||
`Can't reset "${bundle.name || "bundle"}" children — it isn't linked to a library bundle. Re-link it first.`,
|
||||
{ severity: "error" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Delta-undo capture: the original module ref keeps its `_uid` so
|
||||
// Undo can find it in live state regardless of position. Reset
|
||||
// only swaps payload/instance/payload_hash; the _uid stays put,
|
||||
@@ -1820,8 +2026,7 @@ async function resetChildToBundleSnapshot(idx: number): Promise<void> {
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
pushToast(`Reset failed: ${msg}`, { severity: "error" });
|
||||
pushToast(bundleResetErrorMessage(e, bundle.name), { severity: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1912,21 +2117,39 @@ function openBundleContextMenu(ev: MouseEvent, uid: string): void {
|
||||
onSelect: () => toggleBundleEnabled(uid, !target.enabled),
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: "Reset to library snapshot",
|
||||
icon: "pi-refresh",
|
||||
subtitle: "Replace children with frozen library state",
|
||||
onSelect: () => { void resetBundleToLibrary(uid); },
|
||||
},
|
||||
// The two library actions accent themselves to match the bundle's CURRENT
|
||||
// state, so the menu points at the recovery for the badge the user just
|
||||
// saw. Only one lights up: the states are ranked, since a bundle can be
|
||||
// several at once and two competing accents would say nothing.
|
||||
// missing → Push (re-link or add) — nothing to reset FROM
|
||||
// drifted → Reset (pull the library's newer version)
|
||||
// modified → Push (save your local edits up)
|
||||
(() => {
|
||||
const drifted = !isBundleMissingFromLibrary(target)
|
||||
&& isBundleLibraryDrifted(target);
|
||||
return {
|
||||
label: "Reset to library snapshot",
|
||||
icon: "pi-refresh",
|
||||
subtitle: drifted
|
||||
? "Library changed since insert — pull the newer version"
|
||||
: "Replace children with frozen library state",
|
||||
accent: drifted,
|
||||
onSelect: () => { void resetBundleToLibrary(uid); },
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const bundleMissing = isBundleMissingFromLibrary(target);
|
||||
const modified = !bundleMissing
|
||||
&& !isBundleLibraryDrifted(target)
|
||||
&& bundleSnapshotModified(target, value.value.modules);
|
||||
let subtitle = "Rename, retag, overwrite library entry";
|
||||
if (bundleMissing) subtitle = "Not linked to a library entry — re-link or add";
|
||||
else if (modified) subtitle = "Local edits since insert — save them to the library";
|
||||
return {
|
||||
label: "Push to library…",
|
||||
icon: "pi-cloud-upload",
|
||||
subtitle: bundleMissing
|
||||
? "Library entry deleted — re-add to library"
|
||||
: "Rename, retag, overwrite library entry",
|
||||
accent: bundleMissing,
|
||||
subtitle,
|
||||
accent: bundleMissing || modified,
|
||||
onSelect: () => openPushBundleToLibrary(uid),
|
||||
divider: true,
|
||||
};
|
||||
@@ -2027,6 +2250,9 @@ const conflicts = computed<Conflict[]>(() => {
|
||||
props.downstreamWildcardUuids,
|
||||
props.downstreamNestedReachUuids,
|
||||
pairOrphanByUid,
|
||||
// Live-library uuids — a nested @{} ref to any of these resolves at run
|
||||
// time (DB-expanded catalog), so it must not read as a broken ref.
|
||||
new Set(Object.keys(libraryHashes.value ?? {})),
|
||||
);
|
||||
// Filter by user's validation-strictness preference. The accessor
|
||||
// reads from the same module-level state map the panel onChange
|
||||
@@ -2174,9 +2400,16 @@ function conflictTooltip(id: string): string {
|
||||
* lives in `classifyOne` (import-export/collision).
|
||||
*/
|
||||
function matchStateOf(m: ModuleEntry): CollisionState | null {
|
||||
if (!m.payload_hash) return null;
|
||||
if (libraryHashes.value === null) return null;
|
||||
const live = libraryHashes.value[m.id];
|
||||
if (!m.payload_hash) {
|
||||
// No local hash. Only meaningful when the uuid IS in the library — a
|
||||
// workflow copy that lost its hash (malformed import, or a builder that
|
||||
// wrote payload_hash: ""). Treat as drift so the bulk "refresh drifted"
|
||||
// button relinks it. A genuinely inline module (fresh uuid, not in the
|
||||
// library) stays verdict-less exactly as before.
|
||||
return live ? "conflict" : null;
|
||||
}
|
||||
return classifyOne(
|
||||
m.type,
|
||||
m.payload_hash,
|
||||
@@ -2376,17 +2609,32 @@ function isBundleLibraryDrifted(bundle: BundleInstance): boolean {
|
||||
return live !== bundle.inserted_at_hash;
|
||||
}
|
||||
|
||||
/** True when the bundle's library entry has been deleted upstream —
|
||||
* the polled `bundleHashes` map no longer contains its uuid. Pairs
|
||||
* with `isMissingFromLibrary` for modules. Returns false until first
|
||||
* poll lands so we don't flash MISSING before the truth is known. */
|
||||
/** True when the bundle has NO live library entry behind it. Covers both
|
||||
* shapes of that: it carries a `library_id` the polled map no longer knows,
|
||||
* OR it carries no `library_id` at all.
|
||||
*
|
||||
* The second case used to early-return false, so an unlinked bundle could
|
||||
* never show MISSING — leaving MODIFIED as its only badge, which then
|
||||
* compared its children against a snapshot baseline from a library entry that
|
||||
* isn't there. "Modified against what?" was a fair question with no answer.
|
||||
*
|
||||
* Returns false until the first poll lands so we don't flash MISSING before
|
||||
* the truth is known. */
|
||||
function isBundleMissingFromLibrary(bundle: BundleInstance): boolean {
|
||||
const map = bundleHashes.value;
|
||||
if (map === null) return false;
|
||||
if (!bundle.library_id) return false;
|
||||
if (!bundle.library_id) return true;
|
||||
return !(bundle.library_id in map);
|
||||
}
|
||||
|
||||
/** True when the bundle never had a library entry to diverge FROM — no
|
||||
* `library_id` at all. MODIFIED is meaningless in that state: there is no
|
||||
* insert to be modified since. Distinct from "the entry was deleted", where
|
||||
* the local snapshot IS a real baseline and MOD still reads correctly. */
|
||||
function isBundleUnlinked(bundle: BundleInstance): boolean {
|
||||
return bundleHashes.value !== null && !bundle.library_id;
|
||||
}
|
||||
|
||||
// Pending confirm-dialog state. Single slot — only one destructive op
|
||||
// can be in flight at a time, and the bundle ctxmenu / header buttons
|
||||
// are synchronous enough that overlapping calls don't happen in
|
||||
@@ -2806,7 +3054,16 @@ function isModified(m: ModuleEntry): boolean {
|
||||
if (nonEmptyObj(inst.option_weights)) return true;
|
||||
if (inst.mode && inst.mode !== "random") return true;
|
||||
if (inst.pinned_option_id) return true;
|
||||
if (nonEmptyArr(inst.category_filter)) return true;
|
||||
// `category_filter` is a STRING expr — the old `nonEmptyArr()` test
|
||||
// never matched it, so toggling the pool filter never lit the dot.
|
||||
if (typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") return true;
|
||||
if (inst.exclude_null) return true;
|
||||
// Pick-range override (multi-pick / narrowed). Sparse — absent or 1..1
|
||||
// is the library default; anything else is a behavioural override.
|
||||
if (inst.pick_min != null && inst.pick_min !== 1) return true;
|
||||
if (inst.pick_max != null && inst.pick_max !== 1) return true;
|
||||
if (inst.pick_independent) return true;
|
||||
if (typeof inst.pick_separator === "string" && inst.pick_separator !== "") return true;
|
||||
return false;
|
||||
case "fixed_values":
|
||||
// Library-tracked: `values_overrides` non-empty = user edited entries,
|
||||
@@ -2867,11 +3124,35 @@ function isInternalable(m: ModuleEntry): boolean {
|
||||
}
|
||||
|
||||
function isLocked(m: ModuleEntry): boolean {
|
||||
return typeof m.instance?.locked_seed === "number";
|
||||
return typeof withFrameInstance(m, currentFrame.value).instance?.locked_seed === "number";
|
||||
}
|
||||
function isInternal(m: ModuleEntry): boolean {
|
||||
return !!m.instance?.internal;
|
||||
}
|
||||
/** Kinds whose handlers honor `seed_scope: hold` — wildcard, combine,
|
||||
* fixed_values, and derivation (which freezes its frame-0 output across a
|
||||
* loop, constrained picks + nested @{} refs included). Now equal to
|
||||
* SEED_LOCKABLE_KINDS, but kept distinct on purpose: lock-the-seed and
|
||||
* hold-the-value are different concepts that could diverge again. Gates
|
||||
* the row context-menu's quick Hold item + which RuntimeSections render
|
||||
* the toggle. */
|
||||
const HOLD_KINDS: ReadonlySet<string> = new Set([
|
||||
"wildcard", "combine", "fixed_values", "derivation",
|
||||
]);
|
||||
function isHoldable(m: ModuleEntry): boolean {
|
||||
return HOLD_KINDS.has(m.type);
|
||||
}
|
||||
function isHeld(m: ModuleEntry): boolean {
|
||||
return m.instance?.seed_scope === "hold";
|
||||
}
|
||||
function isOverriddenOnFrame(m: ModuleEntry): boolean {
|
||||
const k = currentFrame.value;
|
||||
return k != null && !!m.iteration_overrides?.[String(k)];
|
||||
}
|
||||
function effectiveLockedSeed(m: ModuleEntry): number | undefined {
|
||||
const v = withFrameInstance(m, currentFrame.value).instance?.locked_seed;
|
||||
return typeof v === "number" ? v : undefined;
|
||||
}
|
||||
|
||||
// ── Formula-driven minWidth ────────────────────────────────────────
|
||||
// Pull-based pattern: createDomWidgetHost reads this through a
|
||||
@@ -2925,6 +3206,22 @@ watch(
|
||||
function toggleLockOnCard(idx: number) {
|
||||
const m = value.value.modules[idx];
|
||||
if (!m) return;
|
||||
const k = currentFrame.value;
|
||||
if (k != null) {
|
||||
// Prefer the seed THIS module actually rolls at frame #k (computed from the
|
||||
// upstream Loop), so the lock pins the frame's real seed — not the single
|
||||
// representative seed a run surfaced. Falls back to last-used / stored when
|
||||
// no Loop drives the node.
|
||||
const frameSeed = props.frameSeedReader?.(k);
|
||||
const lastUsed = props.lastUsedSeedReader?.(m._uid ?? m.id);
|
||||
const fallback = typeof frameSeed === "number" ? frameSeed
|
||||
: typeof lastUsed === "number" ? lastUsed
|
||||
: (typeof m.instance?._ui?.last_locked_seed === "number" ? m.instance._ui.last_locked_seed : 0);
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = toggleFrameLock(m, k, fallback);
|
||||
commitModules(list);
|
||||
return;
|
||||
}
|
||||
const inst = m.instance ?? {};
|
||||
let nextInst: NonNullable<ModuleEntry["instance"]>;
|
||||
if (typeof inst.locked_seed === "number") {
|
||||
@@ -2967,7 +3264,9 @@ function toggleLockOnCard(idx: number) {
|
||||
// map-by-id pass would toggle every sibling at once. Indexing into
|
||||
// the array hits the specific instance the user clicked.
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = { ...m, instance: nextInst };
|
||||
// Unlocking base makes any frame `locked_seed: null` override redundant —
|
||||
// sweep it so the row stops showing a stale "override #k". No-op on lock.
|
||||
list[idx] = dropRedundantFrameLockNulls({ ...m, instance: nextInst });
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
@@ -3181,14 +3480,37 @@ function summaryTokens(m: ModuleEntry): SummaryToken[] {
|
||||
// Card summary respects the per-instance binding override so a
|
||||
// renamed wildcard reads as `$<override>` on the canvas. Engine
|
||||
// applies the same precedence at run time.
|
||||
const inst = m.instance?.variable_binding;
|
||||
const instBind = m.instance?.variable_binding;
|
||||
const payloadBinding = (p.var_binding as string | undefined)?.trim();
|
||||
const binding = (typeof inst === "string" && inst.trim()) || payloadBinding;
|
||||
const opts = Array.isArray(p.options) ? p.options.length : 0;
|
||||
const binding = (typeof instBind === "string" && instBind.trim()) || payloadBinding;
|
||||
const head: SummaryToken = binding ? v(binding) : lit("wildcard");
|
||||
return opts
|
||||
? [head, lit(` · ${opts} option${opts === 1 ? "" : "s"}`)]
|
||||
: [head];
|
||||
const allOpts = (Array.isArray(p.options) ? p.options : []) as WildcardOption[];
|
||||
const total = allOpts.length;
|
||||
if (!total) return [head];
|
||||
const inst = (m.instance ?? {}) as InstanceLike & { pick_min?: number; pick_max?: number };
|
||||
const pmin = typeof inst.pick_min === "number" ? inst.pick_min : 1;
|
||||
const pmax = typeof inst.pick_max === "number" ? Math.max(pmin, inst.pick_max) : pmin;
|
||||
const multiActive = pmax > 1;
|
||||
// "X of Y options" only when a filter / enabled-subset actually narrows
|
||||
// the pool — a plain count when nothing is filtering (keeps the common
|
||||
// case clean). The null-option drop in multi mode alone never triggers it.
|
||||
const hasFilter =
|
||||
(typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") ||
|
||||
Array.isArray(inst.enabled_options);
|
||||
const eligible = allOpts.filter((o) => isEnabled(o, inst, multiActive)).length;
|
||||
const tokens: SummaryToken[] = [
|
||||
head,
|
||||
lit(
|
||||
hasFilter && eligible < total
|
||||
? ` · ${eligible} of ${total} options`
|
||||
: ` · ${total} option${total === 1 ? "" : "s"}`,
|
||||
),
|
||||
];
|
||||
// Multi-pick hint — surface when the instance resolves more than one.
|
||||
if (pmax > 1) {
|
||||
tokens.push(lit(pmin === pmax ? ` · picks ${pmin}` : ` · picks ${pmin}-${pmax}`));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
case "combine": {
|
||||
// Same per-instance precedence as wildcard: rebinding via the v2
|
||||
@@ -3661,11 +3983,32 @@ function moveToEdge(idx: number, edge: "top" | "bottom") {
|
||||
|
||||
function toggleEnabled(idx: number) {
|
||||
if (idx < 0 || idx >= value.value.modules.length) return;
|
||||
const k = currentFrame.value;
|
||||
if (k != null) {
|
||||
// Frame-active: flip the module's per-frame enable override (symmetric —
|
||||
// base-off turns ON for this frame, base-on turns OFF). Pure logic lives in
|
||||
// frame-overrides.toggleFrameEnabled; base `enabled` is never touched here.
|
||||
const m = value.value.modules[idx];
|
||||
if (!m) return;
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = toggleFrameEnabled(m, k);
|
||||
commitModules(list);
|
||||
return;
|
||||
}
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = { ...list[idx], enabled: !list[idx].enabled };
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
// Thin wrappers binding the pure frame-overrides enable helpers to the reactive
|
||||
// `currentFrame` cursor — exposed to ModuleRow via moduleRowCtx.
|
||||
function effectiveEnabled(m: ModuleEntry): boolean {
|
||||
return feEffectiveEnabled(m, currentFrame.value);
|
||||
}
|
||||
function frameEnableOverride(m: ModuleEntry): "on" | "off" | null {
|
||||
return feFrameEnableOverride(m, currentFrame.value);
|
||||
}
|
||||
|
||||
function toggleCollapsed(idx: number) {
|
||||
const target = value.value.modules[idx];
|
||||
if (!target) return;
|
||||
@@ -3682,6 +4025,29 @@ function toggleCollapsed(idx: number) {
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
// Quick "Hold across run" toggle from the row context menu — flips base
|
||||
// `instance.seed_scope` between hold/vary without opening the editor
|
||||
// (the 4-click → 2-click ask). Base-level only: when a loop edit-frame
|
||||
// is active, hold applies to every frame, so we no-op here exactly as
|
||||
// the RuntimeSection disables its toggle. Caller gates on isHoldable;
|
||||
// re-checked here so a stray call can't stamp seed_scope on a kind
|
||||
// (constraint/derivation) whose handler ignores it.
|
||||
function toggleHold(idx: number): void {
|
||||
if (currentFrame.value != null) return;
|
||||
const target = value.value.modules[idx];
|
||||
if (!target || !isHoldable(target)) return;
|
||||
const held = target.instance?.seed_scope === "hold";
|
||||
// Annotate so the literal doesn't widen to `string` in the object
|
||||
// literal — instance.seed_scope is the "vary" | "hold" union.
|
||||
const next: "vary" | "hold" = held ? "vary" : "hold";
|
||||
const list = value.value.modules.map((m, i) =>
|
||||
i === idx
|
||||
? { ...m, instance: { ...(m.instance ?? {}), seed_scope: next } }
|
||||
: m,
|
||||
);
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
/** Bulk collapse/expand. Used by the section-header chevron — one
|
||||
* click flips every card to the same state. Idempotent if all
|
||||
* cards already match the target state (the deep watcher will
|
||||
@@ -3765,7 +4131,7 @@ function openEditModal(idx: number) {
|
||||
editingIdx.value = idx;
|
||||
}
|
||||
|
||||
function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
|
||||
function saveEditedModule(updated: ModuleEntry & { _originalId?: string; _relinkFrom?: string }) {
|
||||
// Phase B: index-based replacement — sibling rows share `m.id`, so
|
||||
// mapping `m.id === updated.id` would clobber every sibling at once.
|
||||
// We use `editingIdx` captured at modal-open to target the specific
|
||||
@@ -3776,11 +4142,42 @@ function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
|
||||
editingIdx.value = null;
|
||||
return;
|
||||
}
|
||||
// `_relinkFrom` (the OLD id) is set only on the re-link path — after the
|
||||
// row is committed with its new id, walk the whole list so sibling @{}
|
||||
// refs + constraint source/target follow. Captured before we strip markers.
|
||||
const relinkFrom = updated._relinkFrom;
|
||||
const cleaned: ModuleEntry = { ...updated };
|
||||
delete (cleaned as { _originalId?: string })._originalId;
|
||||
delete (cleaned as { _relinkFrom?: string })._relinkFrom;
|
||||
const list = [...value.value.modules];
|
||||
list[targetIdx] = cleaned;
|
||||
const k = currentFrame.value;
|
||||
if (k == null) {
|
||||
list[targetIdx] = cleaned; // base edit — unchanged behavior
|
||||
} else {
|
||||
const baseModule = value.value.modules[targetIdx]; // base: payload + base instance intact
|
||||
const fields = FRAME_OVERRIDABLE_FIELDS[baseModule.type] ?? [];
|
||||
const delta = diffInstance(baseModule.instance, cleaned.instance, fields);
|
||||
// base payload + base instance preserved; the frame's override = the instance delta (replace, not merge)
|
||||
let next = clearFrameOverride(baseModule, k);
|
||||
if (Object.keys(delta).length) next = setFrameOverride(next, k, delta);
|
||||
list[targetIdx] = next;
|
||||
}
|
||||
// A base-unlock (here via the modal, or via the row toggle) makes any frame
|
||||
// `locked_seed: null` override redundant — sweep it so the row stops showing
|
||||
// a stale "override #k". No-op while the base stays locked.
|
||||
list[targetIdx] = dropRedundantFrameLockNulls(list[targetIdx]);
|
||||
commitModules(list);
|
||||
if (relinkFrom && relinkFrom !== updated.id) {
|
||||
// The re-linked row already carries its new id (list[targetIdx]); walk
|
||||
// EVERY module so sibling nested @{} refs + constraint source/target that
|
||||
// pointed at the old uuid follow to the library uuid. applyRelink leaves
|
||||
// the already-swapped row's id alone (its id !== relinkFrom).
|
||||
const remapped = applyRelink(
|
||||
value.value.modules as unknown as Record<string, unknown>[],
|
||||
{ oldId: relinkFrom, newId: updated.id, newPayloadHash: updated.payload_hash ?? "" },
|
||||
) as unknown as ModuleEntry[];
|
||||
commitModules(remapped);
|
||||
}
|
||||
if (updated._originalId && updated._originalId !== updated.id) {
|
||||
nextTick(() => {
|
||||
const el = document.querySelector<HTMLElement>(
|
||||
@@ -3795,6 +4192,18 @@ function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
|
||||
editingIdx.value = null;
|
||||
}
|
||||
|
||||
/** Revert the currently-edited module's frame-#k override back to the
|
||||
* base instance (i.e. remove the iteration_overrides[k] entry entirely).
|
||||
* Called from the frame banner's "Revert to base" button in ModuleEditModal. */
|
||||
function revertEditingFrame(): void {
|
||||
const idx = editingIdx.value;
|
||||
const k = currentFrame.value;
|
||||
if (idx == null || k == null) return;
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = clearFrameOverride(value.value.modules[idx], k);
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the BundleInstanceModal for `uid`. Snapshots the live
|
||||
* BundleInstance into `bundleDraft` so the user's typing in
|
||||
@@ -3998,6 +4407,26 @@ function openContextMenu(ev: MouseEvent, m: ModuleEntry, idx: number) {
|
||||
const items: ContextMenuItem[] = [
|
||||
{ label: "Edit", icon: "pi-pencil", onSelect: () => openEditModal(idx) },
|
||||
];
|
||||
// Quick Hold toggle — kept at the top so it's a 2-click reach
|
||||
// (right-click → Hold) instead of opening the editor and digging into
|
||||
// Runtime. Only for kinds that honor seed_scope. Hold is base-level;
|
||||
// when a loop edit-frame is active it applies to every frame, so we
|
||||
// surface it disabled with a hint rather than silently writing it.
|
||||
if (isHoldable(m)) {
|
||||
const held = isHeld(m);
|
||||
const frameActive = currentFrame.value != null;
|
||||
items.push({
|
||||
label: held ? "Release hold" : "Hold across run",
|
||||
icon: "pi-link",
|
||||
disabled: frameActive,
|
||||
subtitle: frameActive
|
||||
? "Applies to every frame — switch to base to change"
|
||||
: held
|
||||
? undefined
|
||||
: "Freeze this value for the whole batch",
|
||||
onSelect: () => toggleHold(idx),
|
||||
});
|
||||
}
|
||||
// Refresh + Save are mutually exclusive in normal use (a module is
|
||||
// either drifted OR missing OR clean), so hiding the inactive entry
|
||||
// beats greying it — matches Save's existing conditional-push pattern
|
||||
@@ -4006,6 +4435,10 @@ function openContextMenu(ev: MouseEvent, m: ModuleEntry, idx: number) {
|
||||
items.push({
|
||||
label: "Refresh from library",
|
||||
icon: "pi-refresh",
|
||||
// Accented + explained: drift is the state where the menu HAS the fix
|
||||
// for the badge the user just saw, so it should say so.
|
||||
subtitle: "Library changed since insert — pull the newer version",
|
||||
accent: true,
|
||||
onSelect: () => { void refreshOne(idx); },
|
||||
divider: true,
|
||||
});
|
||||
@@ -4018,11 +4451,17 @@ function openContextMenu(ev: MouseEvent, m: ModuleEntry, idx: number) {
|
||||
// greys out the "Update existing" button when payload_hash is empty.
|
||||
if (!!m.payload) {
|
||||
const missing = isMissingFromLibrary(m);
|
||||
// Local overrides with no drift → pushing is what the user probably wants.
|
||||
// Ranked below `missing` so only one library action ever accents.
|
||||
const modified = !missing && !isDrifted(m) && isModified(m);
|
||||
let subtitle: string | undefined;
|
||||
if (missing) subtitle = "Not linked to a library entry — re-link or add";
|
||||
else if (modified) subtitle = "Local edits since insert — save them to the library";
|
||||
items.push({
|
||||
label: "Push to library…",
|
||||
icon: "pi-cloud-upload",
|
||||
subtitle: missing ? "Library entry deleted — re-add as new entry" : undefined,
|
||||
accent: missing,
|
||||
subtitle,
|
||||
accent: missing || modified,
|
||||
onSelect: () => openPushToLibrary(idx),
|
||||
divider: true,
|
||||
});
|
||||
@@ -4684,8 +5123,33 @@ const moduleRowCtx: ModuleRowCtx = {
|
||||
pairingFor,
|
||||
viaInboundFor,
|
||||
contributorsFor,
|
||||
currentFrame,
|
||||
isHeld,
|
||||
isOverriddenOnFrame,
|
||||
effectiveLockedSeed,
|
||||
effectiveEnabled,
|
||||
frameEnableOverride,
|
||||
};
|
||||
provide(ModuleRowCtxKey, moduleRowCtx);
|
||||
// Pools this node holds, for the nested-ref hover card. Provided rather than
|
||||
// threaded: `RefChip` sits six components below here (row → edit modal →
|
||||
// section → value chips → preview → chip) and not one layer in between has any
|
||||
// business knowing about option pools. Recomputes with the module list, so
|
||||
// picking or removing a wildcard moves the card's numbers immediately.
|
||||
provide(CONTEXT_POOLS_KEY, computed(() => buildContextPools(value.value.modules)));
|
||||
// Companion lookup for the same card: when the ref falls back to the library,
|
||||
// this names any OTHER node holding the pool, so "why isn't it using the one
|
||||
// I can see right there" has an answer on the card instead of being a puzzle.
|
||||
provide(FOREIGN_POOL_LOOKUP_KEY, (uuid: string): string[] =>
|
||||
props.wildcardHomesReader?.(uuid) ?? []);
|
||||
// Seed source for the edit-modal's Lock button — resolves the seed the module
|
||||
// actually rolled (frame #k's captured seed when a frame is active, else the
|
||||
// last-run seed) so locking pins what the user saw, not a random number.
|
||||
provide(LockSeedKey, (m: ModuleEntry): number | null => {
|
||||
const k = currentFrame.value;
|
||||
if (k != null) return props.frameSeedReader?.(k) ?? null;
|
||||
return props.lastUsedSeedReader?.(m._uid ?? m.id) ?? null;
|
||||
});
|
||||
|
||||
const bundleFrameCtx: BundleFrameCtx = {
|
||||
bundleChildDriftCount,
|
||||
@@ -4695,7 +5159,10 @@ const bundleFrameCtx: BundleFrameCtx = {
|
||||
bundleLockState,
|
||||
bundleHeaderGap,
|
||||
isBundleDropTarget,
|
||||
isBundleSnapshotModified: (b: BundleInstance) => bundleSnapshotModified(b, value.value.modules),
|
||||
// Suppressed for an unlinked bundle — there is no insert for its children to
|
||||
// have diverged from, so MOD would be diffing against a ghost.
|
||||
isBundleSnapshotModified: (b: BundleInstance) =>
|
||||
!isBundleUnlinked(b) && bundleSnapshotModified(b, value.value.modules),
|
||||
recentDropUids,
|
||||
pulseDelayFor,
|
||||
toggleBundleCollapsed,
|
||||
@@ -4944,14 +5411,17 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
:visible="editingModule !== null"
|
||||
:module="editingModule"
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-producers="allVarProducers"
|
||||
:upstream-resolved="resolvedForEditing"
|
||||
:sibling-vars="siblingNodeVars"
|
||||
:sibling-modules="value.modules"
|
||||
:chain-modules="chainModules"
|
||||
:via-option-pairs="editingModuleViaOptionPairs"
|
||||
:last-used-seed-reader="lastUsedSeedReader"
|
||||
:current-frame="currentFrame"
|
||||
@save="saveEditedModule"
|
||||
@close="editingIdx = null"
|
||||
@revert-frame="revertEditingFrame"
|
||||
/>
|
||||
|
||||
<!-- Bundle edit modal — sibling to ModuleEditModal, dispatched
|
||||
@@ -5725,6 +6195,12 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
* size to 12 to match Module density. */
|
||||
.wp-row-type-icon { color: var(--wp-kind-wildcard); }
|
||||
.wp-row-type-icon .pi { font-size: 12px; }
|
||||
/* The bare rules above tie with row-primitives.css's grey default, and that
|
||||
* file is copied into several lazy chunks: whichever chunk injects last wins,
|
||||
* so opening a modal or adding an Injector turned wildcard icons grey. The
|
||||
* [data-kind] selectors below outrank it whatever the load order. */
|
||||
.wp-module[data-kind="wildcard"] .wp-row-type-icon { color: var(--wp-kind-wildcard); }
|
||||
.wp-module .wp-row-type-icon .pi { font-size: 12px; }
|
||||
.wp-module[data-kind="combine"] .wp-row-type-icon { color: var(--wp-kind-combine); }
|
||||
.wp-module[data-kind="derivation"] .wp-row-type-icon { color: var(--wp-kind-derivation); }
|
||||
.wp-module[data-kind="constraint"] .wp-row-type-icon { color: var(--wp-kind-constraint); }
|
||||
|
||||
@@ -590,6 +590,26 @@ describe("ModuleEditModal — derivation v2 dispatcher", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Frame banner + revert-to-base (Phase E.1) ───────────────────────────
|
||||
|
||||
describe("ModuleEditModal — frame banner", () => {
|
||||
beforeEach(() => _resetForTests());
|
||||
|
||||
it("shows the frame banner + emits revert-frame when a frame is active", async () => {
|
||||
const w = mount(ModuleEditModal, { ...mountOpts, props: { visible: true, module: makeWildcard(), currentFrame: 1 } });
|
||||
await nextTick();
|
||||
expect(w.find('[data-test="modal-frame-banner"]').exists()).toBe(true);
|
||||
await w.find('[data-test="modal-frame-revert"]').trigger("click");
|
||||
expect(w.emitted("revert-frame")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("no frame banner on base (currentFrame null)", async () => {
|
||||
const w = mount(ModuleEditModal, { ...mountOpts, props: { visible: true, module: makeWildcard(), currentFrame: null } });
|
||||
await nextTick();
|
||||
expect(w.find('[data-test="modal-frame-banner"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Constraint v2 dispatcher branch ────────────────────────────────────
|
||||
|
||||
describe("ModuleEditModal — constraint v2 dispatcher", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount } from "vue";
|
||||
import { ref, computed, watch } from "vue";
|
||||
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
import type { ModuleEntry, ModuleEntryKind } from "../../widgets/_shared";
|
||||
import { type ResolvedValue } from "../../widgets/richTokenize";
|
||||
@@ -17,6 +18,7 @@ import CombineInstanceModal from "./editors/combine/CombineInstanceModal.vue";
|
||||
import DerivationInstanceModal from "./editors/derivation/DerivationInstanceModal.vue";
|
||||
import ConstraintInstanceModal from "./editors/constraint/ConstraintInstanceModal.vue";
|
||||
import type { ChainModule, PairingBadge } from "../../extension/constraint-pairs";
|
||||
import type { VarProducerLike } from "../../manager/components/RefChip.vue";
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -24,6 +26,9 @@ const props = defineProps<{
|
||||
module: ModuleEntry | null;
|
||||
/** Variable names defined upstream — used for autocomplete + validity checks. */
|
||||
upstreamVars?: string[];
|
||||
/** `$var` → its producing module + node. Forwarded to the per-kind modals so
|
||||
* var chips can name where a value comes from. */
|
||||
upstreamProducers?: Record<string, VarProducerLike>;
|
||||
/** Resolved upstream `$name → value` map. Combine modal uses this
|
||||
* to render a live preview pane with vars substituted. */
|
||||
upstreamResolved?: Record<string, ResolvedValue>;
|
||||
@@ -51,11 +56,15 @@ const props = defineProps<{
|
||||
* generic chain seed.
|
||||
*/
|
||||
lastUsedSeedReader?: (moduleId?: string) => number | null;
|
||||
/** When a frame override context is active, the 0-based frame index.
|
||||
* Null / undefined = editing the base (no banner shown). */
|
||||
currentFrame?: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "save", value: ModuleEntry): void;
|
||||
(e: "close"): void;
|
||||
(e: "revert-frame"): void;
|
||||
}>();
|
||||
|
||||
// Draft state — owned by the modal. Cancel discards, Save commits via emit.
|
||||
@@ -63,18 +72,22 @@ const emit = defineEmits<{
|
||||
// via JSON round-trip (Proxy-safe at every depth, unlike structuredClone).
|
||||
const draft = ref<ModuleEntry | null>(null);
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v && props.module) {
|
||||
watch([() => props.visible, () => props.module], () => {
|
||||
if (props.visible && props.module) {
|
||||
// Re-clone on open AND whenever the source module changes under an open
|
||||
// modal — e.g. "Revert to base" clears the frame override, so editingModule
|
||||
// recomputes and the draft must resync to the base instance. (During normal
|
||||
// editing the draft is mutated locally; props.module identity is stable, so
|
||||
// this does not clobber in-progress edits.)
|
||||
draft.value = JSON.parse(JSON.stringify(props.module));
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
} else {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
draft.value = null;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
||||
|
||||
// Keys arrive from ModalShell, not from window: the modal shield stops
|
||||
// inside-modal keys at the overlay so they cannot reach ComfyUI's global
|
||||
// shortcuts, which means a window listener here would never fire.
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (!props.visible) return;
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
|
||||
@@ -239,7 +252,7 @@ function onSaveToLibraryClick(): void {
|
||||
}
|
||||
|
||||
interface PushSaveResult {
|
||||
mode: "update" | "fork" | "reattach";
|
||||
mode: "update" | "fork" | "reattach" | "relink";
|
||||
id: string;
|
||||
payload_hash: string;
|
||||
bundles_updated: string[];
|
||||
@@ -253,12 +266,17 @@ function onPushToLibrarySaved(result: PushSaveResult): void {
|
||||
return;
|
||||
}
|
||||
setLibraryHash(result.id, result.payload_hash);
|
||||
if (result.mode === "fork" || result.mode === "reattach") {
|
||||
if (result.mode === "fork" || result.mode === "reattach" || result.mode === "relink") {
|
||||
// Fork: the new entry replaces the draft's identity; `_originalId`
|
||||
// lets saveEditedModule swap the row by old id during the next
|
||||
// save(). Reattach: same wiring — the dead uuid gets swapped for
|
||||
// the new one so MISSING clears.
|
||||
(draft.value as ModuleEntry & { _originalId?: string })._originalId = draft.value.id;
|
||||
// the new one so MISSING clears. Relink: point the detached row at an
|
||||
// EXISTING library uuid; `_relinkFrom` (the OLD id, captured before the
|
||||
// swap) additionally tells saveEditedModule to remap sibling @{} refs +
|
||||
// constraint source/target so cross-references follow the new uuid.
|
||||
const marked = draft.value as ModuleEntry & { _originalId?: string; _relinkFrom?: string };
|
||||
marked._originalId = draft.value.id;
|
||||
if (result.mode === "relink") marked._relinkFrom = draft.value.id;
|
||||
draft.value.id = result.id;
|
||||
draft.value.payload_hash = result.payload_hash;
|
||||
draft.value.meta = {
|
||||
@@ -269,7 +287,9 @@ function onPushToLibrarySaved(result: PushSaveResult): void {
|
||||
pushToast(
|
||||
result.mode === "reattach"
|
||||
? `Re-attached "${result.name}" to library`
|
||||
: `Saved as new library entry "${result.name}"`,
|
||||
: result.mode === "relink"
|
||||
? `Re-linked "${result.name}" to library`
|
||||
: `Saved as new library entry "${result.name}"`,
|
||||
{ severity: "success" },
|
||||
);
|
||||
save();
|
||||
@@ -381,7 +401,24 @@ function cancel() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalShell :visible="visible" @close="cancel">
|
||||
<ModalShell :visible="visible" @close="cancel" @keydown="onKeydown">
|
||||
<!-- Column so the frame banner stacks ABOVE the per-kind modal card
|
||||
(each per-kind modal renders a plain card; ModalShell's wrapper is
|
||||
a centered flex ROW, so a bare banner sibling would land beside the
|
||||
card). align-self:stretch on the banner spans it to the card width. -->
|
||||
<div class="wp-frame-edit-body">
|
||||
<!-- Frame-context banner — shown when the user is editing overrides
|
||||
for a specific iteration frame (#k). Sits above all per-kind
|
||||
modal bodies. Invisible on base editing (currentFrame == null). -->
|
||||
<div
|
||||
v-if="currentFrame != null"
|
||||
class="wp-modal-frame-banner"
|
||||
data-test="modal-frame-banner"
|
||||
>
|
||||
<span>Editing frame #{{ currentFrame + 1 }} — changes affect only this frame.</span>
|
||||
<button type="button" data-test="modal-frame-revert" @click="emit('revert-frame')">Revert #{{ currentFrame + 1 }} to base</button>
|
||||
</div>
|
||||
|
||||
<!-- v2 wildcard branch — single-pane tailored modal renders its
|
||||
own header/footer/body. No v1 chrome (no .wp-medit wrapper,
|
||||
no tab strip, no LibraryRoundTripActions footer). -->
|
||||
@@ -390,8 +427,10 @@ function cancel() {
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-producers="upstreamProducers"
|
||||
:sibling-vars="siblingVars"
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -404,6 +443,7 @@ function cancel() {
|
||||
v-else-if="draft && draft.type === 'fixed_values'"
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -419,8 +459,10 @@ function cancel() {
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-producers="upstreamProducers"
|
||||
:upstream-resolved="upstreamResolved"
|
||||
:sibling-vars="siblingVars"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -440,8 +482,10 @@ function cancel() {
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-producers="upstreamProducers"
|
||||
:sibling-vars="siblingVars"
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -460,13 +504,14 @@ function cancel() {
|
||||
:is-modified="instanceModified"
|
||||
:sibling-modules="siblingModules"
|
||||
:chain-modules="chainModules"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@save-to-library="onSaveToLibraryClick"
|
||||
@clear-all-overrides="onClearAllOverrides"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</ModalShell>
|
||||
|
||||
<!-- Themed confirm dialog — replaces window.confirm() for any
|
||||
@@ -503,5 +548,42 @@ function cancel() {
|
||||
* (WildcardInstanceModal, FixedValuesInstanceModal, etc.). The
|
||||
* `.wp-medit` chrome was the v1 tabbed shell — removed alongside
|
||||
* the v1 dispatch in the 2026-05-10 cleanup. */
|
||||
|
||||
.wp-frame-edit-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
.wp-modal-frame-banner {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 11px;
|
||||
border-radius: 6px;
|
||||
/* solid surface (amber mixed into the modal bg, not over the canvas) so the
|
||||
* text stays readable; amber = the override/warning accent used elsewhere. */
|
||||
background: color-mix(in oklab, var(--wp-amber) 14%, var(--wp-bg2));
|
||||
border: 1px solid color-mix(in oklab, var(--wp-amber) 38%, transparent);
|
||||
color: var(--wp-amber);
|
||||
font: 500 11px var(--wp-font-sans);
|
||||
}
|
||||
.wp-modal-frame-banner button {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid color-mix(in oklab, var(--wp-amber) 55%, transparent);
|
||||
background: color-mix(in oklab, var(--wp-amber) 20%, transparent);
|
||||
color: var(--wp-amber);
|
||||
font: 600 10px var(--wp-font-sans);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wp-modal-frame-banner button:hover {
|
||||
background: color-mix(in oklab, var(--wp-amber) 32%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -316,7 +316,14 @@
|
||||
:data-kind="byId.get(id)?.type"
|
||||
>
|
||||
<span class="wp-picker__sel-dot"></span>
|
||||
{{ byId.get(id)?.name ?? id }}
|
||||
<!-- Wrapped in its own span so it can ellipsis-clip. As a bare
|
||||
text node it wrapped onto a second line inside a chip fixed at
|
||||
22px tall, so a long name like "Accent piece (color-neutral)"
|
||||
spilled out of its own pill. -->
|
||||
<span
|
||||
class="wp-picker__sel-name"
|
||||
:title="byId.get(id)?.name ?? id"
|
||||
>{{ byId.get(id)?.name ?? id }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-picker__sel-close"
|
||||
@@ -1443,6 +1450,12 @@ onBeforeUnmount(detachCaptureListeners);
|
||||
font-weight: 500;
|
||||
max-width: 200px;
|
||||
}
|
||||
.wp-picker__sel-name {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wp-picker__sel-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 999px;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user