Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6331b25241 | ||
|
|
c8689d56e4 | ||
|
|
3ba16e874c | ||
|
|
42d8c7bbfc | ||
|
|
a4cbe72086 | ||
|
|
4e8603b4cc | ||
|
|
e1ac84f6d8 | ||
|
|
af3bb60981 | ||
|
|
e788306291 | ||
|
|
eabff31c1c | ||
|
|
c79b88d464 | ||
|
|
61b828c716 | ||
|
|
58e49cc635 | ||
|
|
585a745683 | ||
|
|
0fd50b1a18 | ||
|
|
e622850349 | ||
|
|
39e99db53d | ||
|
|
3768c8ec44 | ||
|
|
dbb645feb5 | ||
|
|
e0776891c8 | ||
|
|
1476fafd8a | ||
|
|
83bce45af6 | ||
|
|
09b06d1dc7 | ||
|
|
dd1bb8639c | ||
|
|
4e2110ad4b | ||
|
|
154c159bc0 | ||
|
|
882db0a81c | ||
|
|
5c0a207f06 | ||
|
|
d88c067183 | ||
|
|
7584c794e2 | ||
|
|
04b2eeff8f | ||
|
|
2ff5e28ac0 | ||
|
|
0d1e25ade3 | ||
|
|
6411e591ca | ||
|
|
23cb24c6fd | ||
|
|
596750bd1a | ||
|
|
3e6b6e09a5 | ||
|
|
bea49f08d3 | ||
|
|
63769026fb | ||
|
|
579a8be4d3 | ||
|
|
950990a19c | ||
|
|
e4372720f8 | ||
|
|
8cdff4833a | ||
|
|
fc62523193 | ||
|
|
3018c17fc7 | ||
|
|
25f7a10ba3 | ||
|
|
f460ae916f | ||
|
|
024d0a0abd | ||
|
|
14014a952c | ||
|
|
96fba93e80 | ||
|
|
af162ef953 | ||
|
|
79646b6d94 | ||
|
|
0553f920be | ||
|
|
35714084be | ||
|
|
1bf4946260 | ||
|
|
b5d534df6b | ||
|
|
f05c592c46 | ||
|
|
b6acd09a1f | ||
|
|
acbf0e39ae | ||
|
|
66fa0ae4a8 | ||
|
|
2c5b883187 | ||
|
|
5c99c90053 | ||
|
|
2c992c6ca5 | ||
|
|
2f1fd07f55 | ||
|
|
1b2013fe69 | ||
|
|
200e3823d1 | ||
|
|
e5cba40374 | ||
|
|
24aea49f3b | ||
|
|
ad71dfdf71 | ||
|
|
44d2c824bc | ||
|
|
4463f2406b | ||
|
|
a63a615514 | ||
|
|
a0c6849878 | ||
|
|
9cebdbd994 | ||
|
|
6ad975c1cd | ||
|
|
e8b50ecaf5 | ||
|
|
7c167444db | ||
|
|
7afa7518bc | ||
|
|
2951d610c5 | ||
|
|
3e3e26d2b2 | ||
|
|
12875f9053 | ||
|
|
3ad1eaacc5 | ||
|
|
e6217bb557 | ||
|
|
e75beafd70 | ||
|
|
7f7e3cef71 | ||
|
|
e5f3301971 | ||
|
|
881570e887 | ||
|
|
56774a2d99 | ||
|
|
413b876dfe | ||
|
|
2978025cbe | ||
|
|
a984ee4a31 | ||
|
|
67d5be6277 | ||
|
|
d9ce967de7 | ||
|
|
d8691d8811 | ||
|
|
aed9501b34 | ||
|
|
1e42033ac0 | ||
|
|
2a53e3edf5 | ||
|
|
c39701773a | ||
|
|
29b14d2c71 | ||
|
|
52259acd09 | ||
|
|
e588392e14 | ||
|
|
6d25e4aa74 | ||
|
|
d596af293c | ||
|
|
f84379c0ae | ||
|
|
be96077b3c | ||
|
|
174c82dcc5 | ||
|
|
143f076751 | ||
|
|
a165921889 | ||
|
|
9e5cd77148 | ||
|
|
05c6bd95ec | ||
|
|
35f02a1122 | ||
|
|
72212f1e6a | ||
|
|
cd1ea0ee2e | ||
|
|
0d8d163081 | ||
|
|
e78b46ba36 | ||
|
|
9b4b87a879 | ||
|
|
e370bedf02 | ||
|
|
0fe1827f3f | ||
|
|
3f71ddfcb7 | ||
|
|
77ae7ce888 | ||
|
|
705cdf0a7f | ||
|
|
476c34e79d | ||
|
|
fe27e7f2e3 | ||
|
|
276ad766a6 | ||
|
|
0f592dc77f | ||
|
|
e052a7ae39 | ||
|
|
bf2764239f | ||
|
|
b37204fd68 | ||
|
|
8bd3130fe7 | ||
|
|
45958356f1 | ||
|
|
02f0230c08 | ||
|
|
d664826093 | ||
|
|
a4b34100de | ||
|
|
43c994cc34 | ||
|
|
fd16be87ce | ||
|
|
109565fa00 | ||
|
|
5ecdef15e2 | ||
|
|
8c0b65385c | ||
|
|
04182578dc | ||
|
|
de02f5dd81 | ||
|
|
6dd7f94c0e | ||
|
|
5ce15dfe30 | ||
|
|
9f653a30c3 | ||
|
|
a9c4e3d961 | ||
|
|
96c340b79b | ||
|
|
e1f8bf39d9 | ||
|
|
3df69ba7f4 | ||
|
|
fafad3de75 | ||
|
|
4e6e1050b4 | ||
|
|
46a67c7760 | ||
|
|
abc34f3f8b | ||
|
|
1294e30e3c | ||
|
|
2b0393723d | ||
|
|
db09ef5dcc | ||
|
|
dfa208b44c | ||
|
|
ad14a596e5 | ||
|
|
f7e3348e3d | ||
|
|
0a0f2f6bcf | ||
|
|
e568e94fac | ||
|
|
fcb1d84e7f | ||
|
|
c2f04e3cc9 | ||
|
|
f1edb2163e | ||
|
|
3142dace09 | ||
|
|
770822a5b6 | ||
|
|
8ffa222128 | ||
|
|
44aa09e67c | ||
|
|
3798d9f9b4 | ||
|
|
bd4abead45 | ||
|
|
31d8b3737c | ||
|
|
6eaf4fce05 | ||
|
|
8464b7c983 | ||
|
|
f3985005a4 | ||
|
|
1b985baf29 | ||
|
|
852d9e19ed | ||
|
|
93bb46cef9 | ||
|
|
896cd3d20d | ||
|
|
731cf89101 | ||
|
|
ffa6f56d08 | ||
|
|
a36c8dfc50 | ||
|
|
4b283565f6 | ||
|
|
ea64877bb9 | ||
|
|
79ac441585 | ||
|
|
38c75f3fc0 | ||
|
|
fc40dead3d | ||
|
|
b72a7a5a5f | ||
|
|
aa3b49a3ee | ||
|
|
0628183c74 | ||
|
|
8a9a91b3d2 | ||
|
|
2370ad86b4 | ||
|
|
2c7a79885d | ||
|
|
1c2df86a90 | ||
|
|
1213a5e825 | ||
|
|
ae7a5bd1c8 | ||
|
|
462f8287d3 | ||
|
|
e2d4387de7 | ||
|
|
9f307b06d4 | ||
|
|
828c4d5488 | ||
|
|
888330de6a | ||
|
|
200e6e4d6b | ||
|
|
07c1bbe89c | ||
|
|
af2e223dc1 | ||
|
|
d86032ff58 | ||
|
|
00c884b517 | ||
|
|
dac700337a | ||
|
|
b4b86eb572 | ||
|
|
0f24498704 | ||
|
|
ea8284b482 | ||
|
|
39a7d7094a | ||
|
|
2c51befe66 | ||
|
|
975863bf0f | ||
|
|
f0079ad457 | ||
|
|
ed3f1774b3 | ||
|
|
9e2bdbe8cb | ||
|
|
8f8063615d | ||
|
|
393c4e9ade | ||
|
|
01dfa18d12 | ||
|
|
f8d159dc11 | ||
|
|
a7104fa507 | ||
|
|
346860ff00 | ||
|
|
ef9c8b4519 | ||
|
|
a6d2705b9d | ||
|
|
b6adb0a0bc | ||
|
|
585b751c57 | ||
|
|
6bdb1e072f | ||
|
|
f6311ff8f1 | ||
|
|
809c6124b7 | ||
|
|
3c29cc0142 | ||
|
|
57b480da79 | ||
|
|
43d7901d0d | ||
|
|
9b124333c0 | ||
|
|
628cacfbe9 | ||
|
|
35d6abfa48 | ||
|
|
e6088fc2a1 | ||
|
|
cbb6b9adb5 | ||
|
|
b357380fa1 | ||
|
|
b55a23b529 | ||
|
|
932555c448 | ||
|
|
54e8e1f6cf | ||
|
|
6f6cd6a4fc | ||
|
|
1fde428db1 | ||
|
|
81463e70a8 | ||
|
|
69afcb834f | ||
|
|
7c8876f08c | ||
|
|
61c0918498 | ||
|
|
780f60af35 | ||
|
|
b106111430 | ||
|
|
6eec3b82c3 | ||
|
|
039d982316 | ||
|
|
7c94cb9fea | ||
|
|
4b828e8cb4 | ||
|
|
e7719a76e6 | ||
|
|
cd813c778a | ||
|
|
cbd18181fc | ||
|
|
ec308da9e9 | ||
|
|
141b5f8271 | ||
|
|
6c6be650c0 | ||
|
|
a8db942e43 | ||
|
|
838904900e | ||
|
|
5a5552286e | ||
|
|
ca730fb08d | ||
|
|
c4e9c2eda2 | ||
|
|
0ada2aab7d | ||
|
|
f56d337ea0 | ||
|
|
b38770cfcc | ||
|
|
ec65e6f0c8 | ||
|
|
e4025cadf4 | ||
|
|
6e188d34e9 | ||
|
|
cfdf892b7b | ||
|
|
d29103e70c | ||
|
|
d2575cd0a0 | ||
|
|
d3348ca40f | ||
|
|
51346120bd | ||
|
|
f7c9af7064 | ||
|
|
4821bbcc52 | ||
|
|
9dd63e2ffa | ||
|
|
ede9788cd1 | ||
|
|
520a8b7a89 | ||
|
|
0cd9a51336 | ||
|
|
6cb8cddbe0 | ||
|
|
eb2f6b7c0e | ||
|
|
f30f4e7181 | ||
|
|
6f671d402f | ||
|
|
f816a8050d | ||
|
|
1929da70d9 | ||
|
|
5067ac52d8 | ||
|
|
a156701ce4 | ||
|
|
ad165a8188 | ||
|
|
a849d12bb6 | ||
|
|
7302532057 | ||
|
|
97dd6efb27 | ||
|
|
9d2406f263 | ||
|
|
2ee14ecc96 | ||
|
|
913a58b2aa | ||
|
|
8698905b76 | ||
|
|
97bc9c6bc3 | ||
|
|
9b01dfb00f | ||
|
|
c753a6c30a | ||
|
|
0f77e0b766 | ||
|
|
a404a5064a | ||
|
|
df41733c02 | ||
|
|
2a8009004b | ||
|
|
f29374a658 | ||
|
|
aa53007df2 | ||
|
|
3d03601f9d | ||
|
|
e81b4dc6d1 | ||
|
|
e3025565da | ||
|
|
c294494ebb | ||
|
|
6b26ddf729 | ||
|
|
ea6f3c43ab | ||
|
|
23f4dbb05b | ||
|
|
469e510c5b | ||
|
|
ae47164fb8 | ||
|
|
c89456b456 | ||
|
|
559fd2457b | ||
|
|
7f50cbb898 | ||
|
|
54397e5411 | ||
|
|
cbd7c81e26 | ||
|
|
cbe9932211 | ||
|
|
cc48b0216c | ||
|
|
09d3eccf40 | ||
|
|
bc66782d90 | ||
|
|
00aefb9eed | ||
|
|
68a979630c | ||
|
|
f9f52a6f65 | ||
|
|
9ba29f9bed | ||
|
|
06bfdaa5ae | ||
|
|
ecb192dcde | ||
|
|
e266542305 | ||
|
|
a81f95bce0 | ||
|
|
2081589ee5 | ||
|
|
cd7b77fb24 | ||
|
|
36eb970893 | ||
|
|
e13bf70296 | ||
|
|
16466b32c6 | ||
|
|
61f8ca0c82 | ||
|
|
ce3f4dba1f | ||
|
|
f485a46600 | ||
|
|
9ad487b371 | ||
|
|
acfb24d0ce | ||
|
|
d6633212e0 | ||
|
|
f1d394b543 | ||
|
|
d74390b6c5 | ||
|
|
014c1fe4cf | ||
|
|
9daa71ba2a | ||
|
|
b56cd844e0 | ||
|
|
ad15565a50 | ||
|
|
b5b9134d21 | ||
|
|
fe79bb2109 | ||
|
|
3d4af1cc97 | ||
|
|
1309889ee8 | ||
|
|
afd7d59b75 | ||
|
|
a05d81448a | ||
|
|
cc514f8c22 | ||
|
|
dd80c89068 | ||
|
|
cc5d9c11db | ||
|
|
6b4ca9e963 | ||
|
|
f64871bcd2 | ||
|
|
37f2bac512 | ||
|
|
30c96c08d9 | ||
|
|
c75395f5c3 | ||
|
|
2e7241ecca | ||
|
|
ef172d890e | ||
|
|
ccaa4c1d0a | ||
|
|
50409182f5 | ||
|
|
69e43b88d9 | ||
|
|
8be2407635 | ||
|
|
11dfa43914 | ||
|
|
c217595f4e | ||
|
|
6a91a18fb4 | ||
|
|
b0eda4ef1f | ||
|
|
efaee80cf1 | ||
|
|
8658e7afde | ||
|
|
5208132473 | ||
|
|
00f7010064 | ||
|
|
e6f060a608 | ||
|
|
36ea570556 | ||
|
|
978e9f7750 | ||
|
|
425778924d | ||
|
|
8b6128a097 | ||
|
|
9d273c4897 | ||
|
|
0cc84783a7 | ||
|
|
c7d9c2955b | ||
|
|
6874116776 | ||
|
|
4f6d26f59c | ||
|
|
e9bd2c31f2 | ||
|
|
fa5a2f7f95 | ||
|
|
cc735f29b3 | ||
|
|
4946864f8b | ||
|
|
a7f6333d00 | ||
|
|
f26098780d | ||
|
|
8a65bb9ea5 | ||
|
|
9ecb733e64 | ||
|
|
d61df08233 | ||
|
|
a9d767e72f | ||
|
|
2d0eebcf77 | ||
|
|
bb530c47bb | ||
|
|
8241584784 | ||
|
|
db2e3f9f0b | ||
|
|
5fb01d1879 | ||
|
|
b775058280 | ||
|
|
a35e1fa463 | ||
|
|
b2784adfd4 | ||
|
|
14a1946fe2 | ||
|
|
4d4d0f4753 | ||
|
|
06fc379357 | ||
|
|
52c327c920 | ||
|
|
6ecb7f6c06 | ||
|
|
8340530e67 | ||
|
|
e90e4e4f99 | ||
|
|
2af795f492 | ||
|
|
c3132c2aaf | ||
|
|
05a3b5f4aa | ||
|
|
0cd5f71061 | ||
|
|
f62df5a363 | ||
|
|
c1069ad766 | ||
|
|
74f37ff0ec | ||
|
|
91d4f0c901 | ||
|
|
52a56acb88 | ||
|
|
f07459ce80 | ||
|
|
05ff5b0677 | ||
|
|
ed01b182b8 | ||
|
|
c3a2e6fa1e | ||
|
|
48526d211c | ||
|
|
4de6691137 | ||
|
|
17a82a593d | ||
|
|
bac0b8f757 | ||
|
|
dee6e6ec79 | ||
|
|
ab86fceb35 | ||
|
|
f604ecd063 | ||
|
|
e91ce32cd5 | ||
|
|
50b3c3250e | ||
|
|
c955903b5a | ||
|
|
17d47bb50b | ||
|
|
2d10ee81be | ||
|
|
14541bdfc9 | ||
|
|
fa61ae327d | ||
|
|
e701072068 | ||
|
|
57bb9dac8e | ||
|
|
fa5e71a5e4 | ||
|
|
cf2866dc10 |
@@ -1,3 +1,79 @@
|
||||
# Local configuration and credentials
|
||||
config.json
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
credentials*.json
|
||||
secrets*.json
|
||||
*.local.json
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
|
||||
# Runtime caches, logs, databases, and temporary files
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.cache/
|
||||
cache/
|
||||
logs/
|
||||
tmp/
|
||||
temp/
|
||||
*.cache
|
||||
*.log
|
||||
*.pid
|
||||
*.tmp
|
||||
*.temp
|
||||
*.scan_in_progress
|
||||
*.scan_progress.json
|
||||
.global_scan_in_progress
|
||||
.global_scan_progress.json
|
||||
*.bak
|
||||
*.backup
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# OS and editor-local state
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Node/JS environment junk
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
web/main.js.rebuilt
|
||||
|
||||
# Local notebooks drafts
|
||||
notebooks/
|
||||
api/notebooks/
|
||||
|
||||
# AI Dev Scripts & Temporary text dumps
|
||||
scripts/
|
||||
doctor_code.txt
|
||||
docs/*
|
||||
!docs/architecture/
|
||||
!docs/architecture/*.md
|
||||
!docs/decisions/
|
||||
!docs/decisions/*.md
|
||||
!docs/audits/
|
||||
!docs/audits/*.md
|
||||
AI_SYSTEM_TEMPLATE.md
|
||||
|
||||
# AI Agents Directory
|
||||
.agents/
|
||||
|
||||
# Test files
|
||||
test_*.js
|
||||
test*.mjs
|
||||
tests/
|
||||
|
||||
# Local diagnostic/test artifacts
|
||||
grep_doctor.txt
|
||||
test_folders.json
|
||||
*.bat
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# 开发与维护规范
|
||||
|
||||
适用于本插件的所有修改,包括 Gemini、Codex 和人工维护。目标是让 UX 持续改善,同时让代码职责、状态来源和验证方式保持清楚。
|
||||
用户明确的当前要求优先;确需偏离本规范时,采用最小例外,在交付说明中写明原因、影响和验证。常规实现选择自行处理,不因本规范增加无必要的确认步骤。
|
||||
|
||||
## 1. 修改前先定位责任
|
||||
|
||||
1. 查看 `git status` 和相关差异,保留已有工作;不覆盖、回退或提交他人的无关修改。
|
||||
2. 阅读 [ARCHITECTURE.md](ARCHITECTURE.md),再按阅读地图打开与任务有关的专题,避免每次加载全部文档。
|
||||
3. 搜索目标函数的调用方、相关状态、CSS 选择器及已有辅助函数,再确定修改位置。
|
||||
4. 简短说明本次行为变化和负责它的模块。一个修复优先落在能解释问题的最小责任范围内。
|
||||
|
||||
UX 请求默认允许调整布局、交互和文案,不代表允许改变文件存储位置、接口协议、工作流语义、模型识别依据或用户数据。
|
||||
|
||||
### 小型 UX 修改快速流程
|
||||
|
||||
仅调整文案、图标、颜色、间距或局部交互时:查看相关差异 → 定位负责规则 → 最小修改 → 验证受影响界面 → 本地提交。
|
||||
同一任务已读过且未变化的规范不反复通读;只读相关专题,不做全仓审查,不顺手重构。
|
||||
按第 7 节选择相关检查,不默认运行全部 JS/Python 测试。检查通过后,除非又改了代码或出现新问题,不重复运行。
|
||||
没有改变职责、接口或持久化契约时,不更新架构文档或新增经验记录。提交说明由 AI 根据实际差异编写,无需用户代写。
|
||||
|
||||
### 任务中断后的接续与快照
|
||||
|
||||
- 开工先检查状态和差异。工作区干净时直接继续,不制造空提交。
|
||||
- 上次因额度或其他原因中断,存在属于该任务的改动时,先确认文件范围,再选择性暂存这些文件;不得使用无差别的 `git add .` 收入用户数据、凭据、临时文件或无关工作。
|
||||
- 已完成且有验证记录的改动可补正式提交;尚未验证或未完成的改动可先保存 `chore(checkpoint): <实际改动摘要>` 恢复快照,提交正文明确“未完成/验证待完成”、已做检查和下一步。快照是恢复点,不代表验收通过。
|
||||
- 后续修改和验证另作正式提交,不自动 amend、reset 或改写快照历史。若无法区分遗留文件归属,保留原状并说明,不猜测打包。
|
||||
- 长任务按可独立验证的完整阶段提交,避免把所有 Git 收尾留到最后。测试范围内检查完成后及时提交,不为额外润色拖延快照。
|
||||
- 上述快照和正式提交均只保存在本地,未经明确授权不推送。
|
||||
|
||||
## 2. 模块职责与规模
|
||||
|
||||
- `web/main.js` 负责注册和接线;`ui_sidebar.js` 的 `createDOM` 负责外壳装配。新工具的完整实现放在自己的模块,不继续塞进这两个入口。
|
||||
- 界面模块负责交互和渲染;组合、转换、校验等纯逻辑放在对应领域模块。底层模块不能反向导入上层界面,也不能读取 DOM 或选中节点来暗中决定业务行为。
|
||||
- 一个模块应有可用一句话解释的主要职责。按“谁拥有状态、谁负责关闭、谁发起请求”拆分,不按随意的行号切割。
|
||||
- 复用现有 `ui_lifecycle.js`、`ui_dom.js`、`translation_service.js`、`node_material_actions.js` 等接口。不要为同一行为再复制一套实现。
|
||||
- 通用文件不能成为杂物箱。不要为了省一个文件,把新领域逻辑塞进 `utils`、素材详情渲染器或浏览器主对象。
|
||||
- 不引入没有当前调用方的管理器、适配层、配置项、事件总线或依赖。一次性的简单逻辑可以留在其责任模块内。
|
||||
|
||||
**规模审查阈值:** 普通逻辑文件超过约 600 行,或单函数超过约 100 行时,新增功能前必须检查能否提取独立职责。继续扩展时,在交付说明中解释为什么仍属于同一职责。已有超大模块的小修复不要求顺手重写全文件;语言目录、数据表和样式表不按这个行数阈值拆分。不能通过空壳包装、压缩代码或碎片化文件绕过审查。
|
||||
|
||||
## 3. 状态、关闭与异步
|
||||
|
||||
- 每份可变状态只有一个权威来源。预览、计数、序列化结果从它派生,不维护几份会互相覆盖的数据。
|
||||
- 子视图状态默认留在子视图。跨界面确需保留的状态才放到 `owner`;新增共享字段时说明拥有者、初始化、重置和释放时机。
|
||||
- 父子视图传容器、必要数据和明确回调,不让子模块随意修改父视图的私有 DOM 和字段。
|
||||
- 新建临时窗口优先使用 `createViewScope()`。按钮、Esc、遮罩、替换窗口、父窗口关闭必须走同一个清理入口;重复关闭应安全。
|
||||
- 视图创建的全局事件、观察器、定时任务、拖拽监听和临时预览都要有对应释放路径。禁止仅 `element.remove()` 就当作关闭完成。
|
||||
- 拖拽中关闭也必须释放鼠标监听,恢复此前的 cursor 和 user-select。子窗口消费 Esc 时,不能顺带关闭父窗口。
|
||||
- 视图读取请求使用其生命周期的 `AbortSignal`;重新查询时取消或隔离旧请求。请求返回后检查当前视图和请求身份,不能让旧结果覆盖新内容。
|
||||
- 异步写入画布前捕获原 graph/node/widget/value,返回后复核身份及输入;不能在返回后重新找“当前选中节点”然后直接写。
|
||||
- 已发出的保存可能在窗口关闭后完成。不要假称取消了服务端写入,也不要让完成回调重新打开或修改已关闭的界面。
|
||||
|
||||
提示词工坊的具体状态约定见 [frontend.md](docs/architecture/frontend.md)。不要恢复已删除的嵌入式工坊、旧导入抽屉或第二套草稿来源。
|
||||
|
||||
## 4. CSS 和 UX 迭代
|
||||
|
||||
- 改样式前搜索同名选择器及主题、响应式覆盖,修改实际负责该属性的规则。禁止每轮都在文件末尾追加“最终修复 / override v2”覆盖包。
|
||||
- 变更后删除本次取代的重复声明、失效选择器和旧布局分支;不能仅依靠更高 specificity 让旧实现暂时失效。
|
||||
- 优先使用现有 `--amb-*` 设计变量。新增变量必须有实际用途和清楚的归属,不为每个常量包装一层变量。
|
||||
- 不新增无解释的 `!important`。确因宿主样式或既有层叠边界需要时,在附近注释说明冲突对象与原因,并验证普通模式和受影响主题。
|
||||
- 静态布局和外观放在 CSS。行内样式用于实际运行时值,例如拖动宽度、定位和数据决定的颜色,不用 JavaScript 重写整套固定样式。
|
||||
- 保持插件选择器作用域,避免影响 ComfyUI 的其他按钮、弹窗和输入框。
|
||||
- 添加功能时检查窄宽度、长中文、空列表、加载中、失败和禁用状态,以及焦点、键盘访问和嵌套窗口层级。
|
||||
- 不为了视觉调整改变按钮的写入、覆盖、删除含义。成功提示必须来自真实成功结果,失败和取消不能显示“已应用”。
|
||||
|
||||
## 5. 文案、数据与文件
|
||||
|
||||
- 新增固定界面文案放入 `locales.js`,同步中英文;不继续增加散落的 `lang === 'zh' ? ... : ...` 分支。
|
||||
- 用户输入、文件名、模型名和元数据通过 `textContent` 或已验证的转义工具进入 DOM;不要拼进未转义的 `innerHTML`。
|
||||
- 列表摘要不能当作详情。正文通过对应详情接口读取;分页、去重、空结果和取消语义必须明确。
|
||||
- 路径与文件名在后端使用现有边界校验;不能只检查 `..`。前端校验不能替代后端校验。
|
||||
- 修改模型、封面、附属文件或导入包前先完整校验和暂存。失败时保留旧数据,避免先删除旧文件再尝试保存新文件。
|
||||
- 修改多文件发布流程要覆盖中途失败和冲突。单个 JSON 的原子写入不能冒充整组文件的事务。
|
||||
- 大目录遍历、哈希和大量磁盘 I/O 使用已有工作线程/进程路径,不阻塞 aiohttp 事件循环。
|
||||
- UI 适配不能偷偷迁移用户数据、修改全局配置或改变现有路由与存储格式。确需改协议时同步调用方、兼容策略和测试。
|
||||
|
||||
## 6. 清理旧实现
|
||||
|
||||
- 替换一条路径时搜索它的导入、导出、调用、字段、事件和选择器;确认无用后一起移除。
|
||||
- 不保留注释掉的大段旧代码、`EXTRACTED` 占位、无人使用的备选实现或“以后可能需要”的分支。
|
||||
- 拆模块后检查移动函数引用的闭包变量、导入、回调参数和返回值。语法通过不代表按钮点击不会出现未定义变量。
|
||||
- 不保留两个相互覆盖的 `onclick` 或两条完成同一请求的路径;让所有入口调用同一个实现。
|
||||
- 不用空 `catch`、伪造成功值或任意 optional chaining 掩盖结构错误。可选宿主能力才允许局部降级,并给出准确结果。
|
||||
|
||||
## 7. 按风险验证
|
||||
|
||||
从插件根目录运行相关检查。选择与变化相称的验证,不为纯文案、文档或低风险样式改动制造镜像测试。
|
||||
|
||||
| 修改范围 | 至少验证 |
|
||||
| --- | --- |
|
||||
| 导入、导出、模块拆分 | JS 语法、模块链接,以及受影响按钮的真实执行路径 |
|
||||
| 工坊状态与生命周期 | `node --experimental-vm-modules tests/prompt_ui_lifecycle.mjs` |
|
||||
| 词卡读取、分页 | `node tests/prompt_material_source.mjs` |
|
||||
| 翻译桥接 | `node tests/translation_service_contracts.mjs`,异步节点写入另跑工坊生命周期测试 |
|
||||
| 素材应用、笔记隔离 | `node --experimental-vm-modules tests/material_workspace_flow.mjs` |
|
||||
| 素材提示词协议、配方筛选、拖拽清理 | `node --experimental-vm-modules tests/studio_contracts.mjs` |
|
||||
| CSS、布局、交互 | 实际浏览器验证受影响状态;涉及宿主行为时验证 ComfyUI |
|
||||
| 文件修改、导入、存储 | 对应 Python 测试,至少覆盖成功、冲突和中途失败保留旧数据 |
|
||||
| 仅文档 | 检查链接、命令、实际代码一致性和 Git 差异 |
|
||||
|
||||
- 模拟测试尽量加载实际模块,只替换外部系统边界;不要把待验证的业务函数也 mock 成成功。
|
||||
- 修复故障时,优先补充能复现原故障的行为测试。不能只通过源码字符串“存在某段代码”来证明修复。
|
||||
- UX 明确替换旧交互时,更新测试到新的用户流程,并保留原本要保护的行为;不要仅删除失败断言或重新塞回废弃功能。
|
||||
- 新增应随仓库交付的回归测试要检查 `.gitignore`,确认确实进入版本控制。
|
||||
- 历史本地 Python 测试可能不在 Git 中。使用合适环境运行相关测试,例如 `python -m unittest discover -s tests -p 'test_*.py'`,核对实际发现的数量;零项测试不算通过。
|
||||
- 无法运行真实宿主、网络服务或完整测试时,准确说明缺口;不要将模拟画布、语法检查或已有通过数量称为本轮宿主验证。
|
||||
|
||||
## 8. 文档与交付
|
||||
|
||||
- 架构文档只记录职责、数据流、契约和关键约束。颜色、像素值及审美描述以 CSS 为准,不每次 UX 更新都往架构入口追加长篇设计说明。
|
||||
- 改动架构时更新最窄的相关专题。修改规范时以本文件为唯一正文,`GEMINI.md` 只保留阅读入口,避免两套要求漂移。
|
||||
- 完成前审查差异、死代码、临时文件和空白错误;保留现有文件编码及换行风格。
|
||||
- 验证后按仓库约定形成一个连贯的本地提交,只包含本任务文件;没有明确授权不推送 GitHub。
|
||||
- 交付说明包含:改了什么与原因、验证结果与限制、剩余问题、本地提交。仅在触发规模阈值或规则例外时额外解释取舍。
|
||||
|
||||
**交付自查:** 新职责放对位置了吗?是否只有一个状态来源?每条关闭路径会释放资源吗?迟到请求会写错对象吗?旧代码和旧 CSS 清掉了吗?新文案已本地化吗?用户文件失败时还在吗?测试验证了实际行为吗?
|
||||
@@ -0,0 +1,265 @@
|
||||
# Anomalous Model Browser Architecture
|
||||
|
||||
This is the short entry point for maintainers and AI agents. It describes the
|
||||
system map, ownership boundaries, and rules that apply across subsystems. Read
|
||||
only the linked topic document relevant to the task; do not load every document
|
||||
by default.
|
||||
|
||||
## Reading map
|
||||
|
||||
Before implementation, follow [AGENTS.md](AGENTS.md), the canonical development
|
||||
and maintenance rules. [GEMINI.md](GEMINI.md) is the Gemini reading entry point.
|
||||
|
||||
The prompt studio reads material prompt payloads through `web/modules/material_prompt_data.js`;
|
||||
list summaries do not contain prompt bodies. See the Material Library contract below.
|
||||
|
||||
| When changing... | Read... |
|
||||
| --- | --- |
|
||||
| Python routes, storage, paths, metadata, covers, or scan state | [`docs/architecture/backend.md`](docs/architecture/backend.md) |
|
||||
| Browser lifecycle, UI state, localization, media, or graph edits | [`docs/architecture/frontend.md`](docs/architecture/frontend.md) |
|
||||
| Update-guide content/versioning or sidebar hover labels | [`docs/architecture/update-guide.md`](docs/architecture/update-guide.md) |
|
||||
| Workflow Recipes, packages, galleries, Parameter Notebooks, or prompt roles | [`docs/architecture/recipes.md`](docs/architecture/recipes.md) |
|
||||
| Material Library snapshots, image parameter details, or reusable node blocks | [`docs/architecture/material-library.md`](docs/architecture/material-library.md) |
|
||||
| Model Doctor, provenance hashes, missing-model recovery, or deep scanning | [`docs/architecture/model-resolution.md`](docs/architecture/model-resolution.md) |
|
||||
| Browser audits, E2E functional bug reports, or verification sign-offs | [`docs/audits/README.md`](docs/audits/README.md) |
|
||||
| Why a current product boundary exists | [`docs/decisions/README.md`](docs/decisions/README.md) |
|
||||
| Recurring implementation mistakes and post-mortems | [`.agents/logs/ai_lessons.md`](.agents/logs/ai_lessons.md) |
|
||||
|
||||
`README.md` is user-facing documentation and `CHANGELOG.md` is user-facing
|
||||
release history. Neither is the source of truth for internal architecture.
|
||||
|
||||
## System shape
|
||||
|
||||
Anomalous Model Browser is a UI-only ComfyUI extension. It registers no custom
|
||||
nodes (`NODE_CLASS_MAPPINGS` is empty). ComfyUI loads the Vanilla JavaScript/CSS
|
||||
frontend from `web/`; the Python package registers `/anomalous/` `aiohttp`
|
||||
routes and performs local filesystem, metadata, recipe, and scan operations.
|
||||
|
||||
```text
|
||||
ComfyUI frontend
|
||||
web/main.js
|
||||
-> web/modules/* UI, graph integration, localization
|
||||
-> /anomalous/* JSON and bounded media requests
|
||||
|
||||
ComfyUI Python server
|
||||
__init__.py
|
||||
-> api/__init__.py route registration
|
||||
-> api/*.py storage and domain operations
|
||||
-> scraper.py explicit metadata/hash enrichment
|
||||
|
||||
User-owned data
|
||||
ComfyUI model folders models and sidecars
|
||||
user/.../anomalous_recipes workflow recipes and recipe assets
|
||||
user/.../anomalous_parameters immutable parameter snapshots
|
||||
user/.../anomalous_materials curated image/workflow material bundles
|
||||
user/.../anomalous_notebooks prompt notes, with legacy originals preserved
|
||||
```
|
||||
|
||||
The frontend and backend communicate through narrow JSON contracts. The
|
||||
frontend must not infer filesystem authority, and the backend must not depend on
|
||||
DOM or live LiteGraph state.
|
||||
|
||||
## Ownership map
|
||||
|
||||
### Backend
|
||||
|
||||
- `api/config.py` owns configured paths and active model-folder types.
|
||||
- `api/path_utils.py` owns containment, filename validation, atomic JSON writes, and resolving
|
||||
a saved model value inside the model folders;
|
||||
`api/utils.py` is a compatibility export surface.
|
||||
- `api/metadata.py` owns sidecar and safetensors metadata extraction.
|
||||
- `api/model_catalog.py`, `api/model_resolution.py`, `api/model_metadata.py`, and
|
||||
`api/model_media.py` own model listing, identity recovery, mutation, and covers;
|
||||
`api/models.py` is a compatibility facade.
|
||||
- `api/scanner.py` and `scraper.py` own scan orchestration and enrichment.
|
||||
- `api/workflow_schema.py`, `api/recipe_schema.py`, `api/recipe_images.py`, and
|
||||
`api/recipe_store.py` own recipe validation/shaping, images, CRUD, history, and
|
||||
integrity receipts; `api/recipes.py` is the HTTP facade.
|
||||
- `api/recipe_packages.py` owns bounded inspect-stage-commit package handling.
|
||||
- `api/parameters.py` owns Parameter Notebook persistence and lookup.
|
||||
- `api/notebooks.py` owns Prompt Note persistence and recoverable legacy copying.
|
||||
- `api/material_schema.py`, `api/material_assets.py`, and `api/material_store.py`
|
||||
own curated material shaping, private assets, persistence/cache, search and
|
||||
lifecycle; `api/materials.py` owns HTTP mapping and compatibility entry points.
|
||||
- `api/media_routes.py`, `api/gallery_routes.py`, `api/translation_routes.py`, and
|
||||
`api/folder_types.py` own the formerly mixed utility route families.
|
||||
- `model_policies.py` owns shared backend rename and protected-category policy.
|
||||
- `model_identity.py` owns file SHA-256 evidence shared with the standalone scanner.
|
||||
- `api/image_search.py` powers the output gallery search (`gallery_images?q=`):
|
||||
it reads only the PNG text chunks before the pixel data (ComfyUI prompt and
|
||||
workflow, A1111 `parameters`), caches one record per image by mtime, and
|
||||
matches all query terms; hex terms of 8+ characters also match recorded model
|
||||
SHA256 values and, via `collect_model_hash_index` in `model_resolution.py`,
|
||||
local model files with that hash. Terms arrive as repeated `term` params, one
|
||||
phrase each. `ui_search_chips.js` is the reusable search-block input (Enter or a
|
||||
comma commits a block); `ui_gallery.js` places it above the gallery.
|
||||
- `api/version_manager.py` owns the plugin's own version: the installed tag/branch
|
||||
(`/anomalous/version`, local only), published releases fetched only on request
|
||||
(GitHub releases API, falling back to `git ls-remote` tags; drafts skipped,
|
||||
pre-releases never counted as latest), switching to a published tag (detached
|
||||
checkout), returning to the default branch (fast-forward only), and undoing the
|
||||
last switch (recorded in the ignored `.anomalous_version.local.json`). Every
|
||||
switch refuses to run over modified tracked files. `ui_version_manager.js` is
|
||||
the version line (shown in the update guide opened by the header "!" button) and panel; nothing goes online until "check for updates"
|
||||
is clicked, and restarts go through ComfyUI Manager's reboot route when present.
|
||||
|
||||
- `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared
|
||||
browser class and extracted-method wiring; `browser_entry.js` owns the single
|
||||
browser instance plus floating/topbar/menu entry behavior (with Dual-Binding PointerEvents drag capture, `lostpointercapture` fail-safe listeners, zero-drift viewport boundary clamping, flicker-free pre-mount coordinate binding and `anomalous-trigger-initializing` smooth opacity fade reveal, default safe placement in the top-left canvas area at `top: 80px; left: 80px;` gracefully avoiding the left sidebar dock, and versioned `anomalous_trigger_pos_v3` atomic JSON coordinate persistence); `entry_controls.js`
|
||||
owns entry mode, trigger sizing/styling normalization, mathematical viewport-safe boundary
|
||||
clamping (`clampFloatingTriggerPosition` with minimum safe boundary `minX=70` preventing left sidebar dock entrapment), clean `loadSavedTriggerPosition`/`saveTriggerPosition` storage drivers, and non-distorting coordinate validation
|
||||
(`isValidSavedTriggerPosition`); `api/__init__.py` injects an aiohttp no-cache middleware (`Cache-Control: no-cache, no-store, must-revalidate`) for extension static files to eliminate browser memory/disk cache desynchronization across regular page refreshes; and `interface_settings.js` owns language and theme preferences.
|
||||
- `ui_sidebar.js` creates the browser shell and folder navigation.
|
||||
`ui_settings_hub.js` owns settings and model-card preferences;
|
||||
`ui_toolbox.js` owns the tool catalog, fixed shortcut bar, and tool dispatch;
|
||||
`ui_browser_navigation.js` owns shared panel hiding/cleanup and workspace return.
|
||||
Scan-wizard launch, single-model precision scans (`triggerDirectModelScan` with strictly factual Civitai vs non-Civitai feedback reporting inferred base-model or match status directly within the bottom-right progress panel and toasts without blocking browser alerts), modal lifecycle ergonomics (backdrop click and Escape key dismissal with listener detachment, scrollable content area with sticky footer actions), post-scan frontend hash and native combo refreshes (`app.refreshComboInNodes()`, `window.anomalous_reload_hashes()`), and polling live in `ui_scan_wizard.js`; folder visibility/order lives in
|
||||
`ui_folder_manager.js`; and help content lives in `ui_help.js`.
|
||||
- `ui_model_sources.js` owns the Model Sources Hub, managing workflow-model and global-library source detection, Civitai/HuggingFace URL attribution, sidecar persistence, and resilient scope switching between active workflow and full local library (with cached library state preservation and reliable re-rendering).
|
||||
- `ui_materials.js`, `ui_material_cards.js`, and `ui_material_application.js` own the Material Library UI, category navigation, card presentation (with grab cursor affordances, explicit drag tooltips, and polymorphic card dragging via `bindPolymorphicMaterialCardDrag`), context-aware drag guidance, relaxed third-party node prompt widget sniffing and injection, and the structured empty state onboarding blueprint guiding users through collection, canvas drag, and prompt studio mixing. Drag precedence prioritizes node hits over blank canvas drops; blank canvas drops auto-instantiate `CLIPTextEncode` nodes for prompt materials with standard colors or open full workflows.
|
||||
- `ui_update_guide.js` and `update_guide_data.js` own the non-intrusive update guide modal (accessible via header button `#anomalous-update-notice-btn` and Help modal; version ID `2026-09-recipes-and-studios`), presenting a 4-step milestone walkthrough (Workflow Recipe Studio, Material Library & Prompt Studio, Model Sources Hub, and Precision Direct Scan with canvas addition) with full bilingual localization. `ui_spotlight_tour.js` provides the interactive spotlight mask tour (`startSpotlightTour`), gliding smooth focal box highlights across topbar workspaces and bottom dock actions with directional tooltip cards and keyboard navigation.
|
||||
- `sidebar_actions.js` owns the sidebar bottom action hover-reveal short labels (100ms), singleton dynamic DOM tooltip bubbles (`#anomalous-sidebar-tooltip-bubble`, 600ms), click/pointerdown instant text/tooltip suppression guards, `isBottomModalOpen` tooltip occlusion guards, and anti-flicker pointer stability.
|
||||
- `tool_registry.js` centralizes metadata, SVG icons (enlarged 20px crisp vector outlines with 2px stroke, #cbd5e1 contrast), and stable IDs for the 9 catalog tools (including Prompt Notes / 提示词笔记) and 2 fixed anchors (Toolbox and Settings).
|
||||
- `shortcut_layout.js` provides tool layout utilities and fallbacks. The bottom shortcut bar maintains the clean fixed 4-tool setup (`scan`, `doctor`, `assistant`, `materials`) plus two anchors (`toolbox`, `settings`) housed in prominent 36px buttons with full click/active text suppression and `.is-active` toggled styling.
|
||||
- `ui_toolbox.js`'s Toolbox modal strictly filters out all tools already present on the bottom bar, presenting a sleek 216px 3-row utility catalog with compact, frameless 44px tiles (providing an elevated silhouette with breathing room for catalog discovery), downward anchor caret pointing to the toolbox trigger button, 0.18s smooth spring pop-in animation, clean click action execution, and zero obstructive text or beta footers.
|
||||
- `ui_grid.js` and model-detail modules own model presentation: `ui_grid.js` manages chunked card rendering,
|
||||
card placeholder ergonomics (eliminating misleading unclickable text in favor of pure centered icon and status),
|
||||
card action buttons (one-click canvas addition with plus icon, model metadata editor, direct precision scanner without wizard modal popups)
|
||||
with absolute positioning cascades immune to tooltip target conflicts, vibrant hover affordance,
|
||||
safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_detail.js`
|
||||
coordinates detail display, `ui_model_editor.js` owns metadata editing, and
|
||||
`ui_model_selector.js` owns advanced selection. `ui_gallery.js` and
|
||||
`ui_gallery_detail.js` own generated-image browsing and workbench lifecycle,
|
||||
with stage interaction in `ui_image_stage.js` and metadata tabs in
|
||||
`ui_image_inspector.js`.
|
||||
- `ui_recipes.js` / `ui_recipe_detail.js`, `ui_notebooks.js`, and `ui_materials.js`
|
||||
own their respective workspace surfaces and persistence flows. `ui_recipes.js` owns
|
||||
the Workflow Recipe studio catalog workspace with search/filter tags, grid/list layout toggle,
|
||||
dedicated top-right modal close anchor (permanently decoupled from the tool button row to prevent wrapping displacement),
|
||||
streamlined action header (preserving active workflow saving while pruning unfinished package
|
||||
import entrypoints), and card browsing. `ui_notebooks.js` owns Prompt Note catalog,
|
||||
sidebar dual-group management (note list + floor quick jump anchor navigation with scrollspy active tracking and tooltip hints),
|
||||
and persistence; `ui_notebook_editor.js` owns unfolded card editing (modularized into single-responsibility
|
||||
sub-functions adhering to the 50-line rule: sticky top action toolbar with floating More popover dropdown and timed two-step delete safety guard,
|
||||
unfolded companion models card with unconstrained multi-column tile flow eliminating nested gallery scrollbars,
|
||||
prompt composer with dynamic field-sizing and compact inline find & replace toolbar, flat material library archiving card with
|
||||
clean single-icon feedback, and unified dark slim scrollbar ergonomics with complete bilingual dictionary coverage in `locales.js`), and
|
||||
`notebook_canvas.js` owns LiteGraph creation. Prompt Notes are integrated as a standard tool in the Toolbox
|
||||
with defensive workspace return state restoration, TDZ-safe summary initialization, and responsive empty-state fallback rendering. `ui_recipe_detail.js`
|
||||
coordinates the Workflow Recipe detail session and model composition. `ui_recipe_overview.js`
|
||||
owns the Overview prompt showcase (with `entry.text` fallback, guarded non-shrinking primary action CTA, and floating Popover More dropdown menu), and `ui_recipe_parameters.js`
|
||||
owns the responsive Parameter Presets workspace (featuring default-expanded raw node parameter inspection,
|
||||
a `clamp(230px, 24vw, 290px)` sidebar with guarded card actions, uncluttered console action bars with deferred status feedback,
|
||||
`minmax(130px, 1fr)` Bento Grid with universal click-to-copy, LoRA cards with flexbox truncation guards,
|
||||
and sticky editor headers).
|
||||
`ui_materials.js` owns Material Library discovery and pagination,
|
||||
`ui_material_cards.js` owns catalog cards, `ui_material_detail.js` owns the
|
||||
full detail surface, and `ui_material_application.js` owns selected-node
|
||||
tracking and explicit material application.
|
||||
Within recipe detail, `ui_recipe_versions.js` owns history comparison/restore,
|
||||
`ui_recipe_gallery.js` owns result cards and direct Image Detail Workbench handoff,
|
||||
`ui_recipe_model_matching.js` owns preview resolution and explicit local replacement,
|
||||
`ui_recipe_metadata.js` owns inline persistence, and `ui_recipe_detail_dom.js` owns
|
||||
the DOM/copy helpers shared by detail subviews. `ui_recipe_catalog.js` owns recipe
|
||||
filters, navigation, dismissible topbar drag guidance strip with localStorage persistence, the 3-step empty-state onboarding blueprint (`renderRecipeEmptyGuide`), and background catalog-wide model readiness resolution (`resolveCatalogRecipeReadiness`), `ui_recipe_cards.js` owns cards and card actions
|
||||
(including `grab` drag affordance, cover `可拖拽` badge, harmonized multi-state model readiness pill with `getRecipeReadiness` synchronizing available, missing, and pending matches with detail overview, and direct canvas drag-and-drop), `ui_recipe_dialogs.js` owns save/edit dialogs, and `ui_recipe_media.js` owns shared cover helpers. Detail sessions synchronize detected model availability back to `owner.recipeRecords` via `syncRecipeReferencesToCatalog`.
|
||||
- `ui_prompt_composer.js` owns the standalone Prompt Studio drawer. Its child
|
||||
views are `ui_prompt_source_deck.js`, `ui_prompt_workbench.js`, and
|
||||
`ui_prompt_inspector.js`. Assembly plan data, track-vs-role separation, and
|
||||
cross-role tail smart-sorting are owned by `prompt_composition.js` and `prompt_studio_data.js`.
|
||||
`ui_prompt_source_deck.js` owns the card preview popover with narrow bridging corridors,
|
||||
differentiated hide timers, and fast dismissal when hovering or clicking library blank space.
|
||||
- `ui_prompt_translator.js` owns the standalone Prompt Translator, featuring robust multilingual/Chinese node prompt extraction (`extractPromptFromNode`), real-time canvas selection synchronization (`app.canvas.onNodeSelected`), automatic prompt injection on open/docked mode, on-demand read/sync controls, guarded selection writeback across single and multi-tab workflows, compact streamlined button ergonomics preventing multi-row wrapping, elastic vertical flex textareas maximizing canvas-side vertical space, and an expanded 460px default sidebar width with automatic backward-compatible width migration. Both translator and
|
||||
studio use `ui_lifecycle.js` for global listeners, request cancellation and
|
||||
resize cleanup. Translation requests go through `translation_service.js`.
|
||||
- `ui_dom.js` provides small DOM/JSON helpers; `material_inspector.js` owns
|
||||
material-specific metadata and parameter rendering.
|
||||
- `ui_doctor.js` owns diagnostics and global scans; `ui_node_assistant.js` owns
|
||||
selected-node assistant history, `ui_node_model_picker.js` owns native combo
|
||||
replacement, and `ui_node_presets.js` owns parameter preset rendering and
|
||||
application. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js`
|
||||
own the remaining explicit graph changes.
|
||||
- `ui_model_sources.js` owns the Model Source Hub (模型来源统一中控中心), providing dual-scope
|
||||
(Workflow and Library) source inspection, external platform jumping, canvas `Note` node generation,
|
||||
`workflow.extra.anomalous_model_sources` metadata synchronization, automated asynchronous model
|
||||
metadata resolution (`resolveWorkflowModelsMetadata`) via `/anomalous/resolve_paths_to_previews` with
|
||||
local sidecar priority detection, and protected read-only link display with deliberate edit-mode
|
||||
unlocking and dirty-state dynamic local persistence (hiding redundant `[Save Local]` buttons until links are modified).
|
||||
- `locales.js` is the shared runtime string catalog. Existing inline bilingual
|
||||
UI strings remain migration debt; new strings belong in the catalog.
|
||||
- `styles.css` is the ordered import manifest for `web/styles/*.css`, which own
|
||||
presentation and theme overrides. Color values, dimensions
|
||||
and visual design descriptions are not duplicated as architectural contracts.
|
||||
|
||||
## Cross-system invariants
|
||||
|
||||
These rules are intentionally summarized here and specified in the linked topic
|
||||
documents.
|
||||
|
||||
1. **Identity is provenance, not naming.** Model Doctor may use a cryptographic
|
||||
hash, exact byte size under the allowed category policy, and target category.
|
||||
Paths, filenames, display names, previews, and fuzzy similarity are never
|
||||
identity evidence.
|
||||
2. **Filesystem input is untrusted.** Backend request paths must pass the shared
|
||||
containment and filename helpers. Checking only for `..` is insufficient on
|
||||
Windows and in the presence of alternate separators, UNC paths, or symlinks.
|
||||
3. **User files are changed transactionally and conservatively.** Recipe writes,
|
||||
imports, graph mutations, and sidecar operations validate before mutation and
|
||||
either complete coherently or restore the prior state.
|
||||
4. **The event loop stays responsive.** Recursive walks, hashing, metadata
|
||||
parsing, and other potentially large disk operations run off the aiohttp
|
||||
event loop. UI rendering and media loading are bounded and cancellable.
|
||||
5. **Host state is preserved.** Browser panels are mutually exclusive,
|
||||
Workspace/model-detail transitions are recoverable, and graph edits use
|
||||
ComfyUI's change/callback contracts.
|
||||
6. **Runtime strings are localized safely.** User-visible copy comes from
|
||||
`locales.js`; dynamic values stay outside dictionaries and enter the DOM as
|
||||
text. Only allowlisted rich content goes through `safe_dom.js`.
|
||||
7. **Optional integrations fail locally.** Missing graph APIs, metadata, network
|
||||
availability, or an optional resolver may disable that capability but must
|
||||
not make the main extension disappear.
|
||||
8. **Presentation data is not authority.** Covers, thumbnails, summaries,
|
||||
cached names, and workflow fingerprints never replace the authoritative
|
||||
serialized workflow or model provenance record.
|
||||
|
||||
## Data and compatibility boundaries
|
||||
|
||||
- Runtime settings and newly saved API keys live in `api/config.json`.
|
||||
`scraper.py` may read the legacy root `config.json` only as a compatibility
|
||||
fallback. API keys are not stored in browser `localStorage`.
|
||||
- Recipes, Parameter Notebooks, and Material Library records/assets are user data outside the extension directory.
|
||||
They are never bundled with or silently migrated into the plugin source.
|
||||
- The extension may integrate with Civitai and optional translation services
|
||||
only through explicit product behavior. External content and dependencies keep
|
||||
their own terms.
|
||||
- Project code and documentation use the MIT license. `TRADEMARKS.md` separately
|
||||
defines the project-name and official-branding boundary.
|
||||
- Internal property names and established routes may remain stable when a
|
||||
user-facing surface is renamed. Do not churn compatibility contracts merely
|
||||
to match presentation wording.
|
||||
|
||||
## Change and snapshot protocol
|
||||
|
||||
Every product-code change should end as one coherent local Git snapshot:
|
||||
|
||||
1. Run checks proportional to the changed behavior.
|
||||
2. Update architecture documentation **only** when the change modifies a module
|
||||
owner, data flow, public/internal interface contract, persistence format,
|
||||
security boundary, or critical invariant.
|
||||
3. When architecture changes, update the narrowest relevant topic document.
|
||||
Update this entry point only if the system map, cross-system invariants, or
|
||||
reading map changed.
|
||||
4. Do not add an architecture entry merely to say that existing boundaries were
|
||||
unchanged. Ordinary fixes belong in code, tests, Git history, and—when useful
|
||||
to users—`CHANGELOG.md`.
|
||||
5. Record a durable lesson in `.agents/logs/ai_lessons.md` only for a recurring
|
||||
trap or a critical failure mode, not as a turn-by-turn work log.
|
||||
6. Create a local commit after verification. Keep unrelated work out of the
|
||||
snapshot and do not push without explicit user authorization.
|
||||
7. Leave a clean worktree, or identify every intentional uncommitted file in the
|
||||
handoff.
|
||||
|
||||
Decision records explain enduring choices; they are not a chronological diary.
|
||||
Git history is the authoritative record of implementation changes. Planning-only
|
||||
documents must be clearly labeled as proposals and must not describe unshipped
|
||||
behavior as current architecture.
|
||||
@@ -1,47 +1,293 @@
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
## v1.4.3 (The Silent Load Update)
|
||||
### 🚀 Enhancements
|
||||
- **Silent Loading by Default**: Reversed the default behavior of the hash resolver. To prevent interrupting workflows for users with massive unindexed local libraries, the aggressive "Missing Models Alert" popups and heavy background hash fetching on startup are now **OFF by default**. You can enable the auto-detect feature manually via the Settings Hub.
|
||||
- **Refined Settings UI**: Updated the terminology in the Settings Hub. The toggle has been re-worded from "Auto-Detect Missing: ON/OFF" to "🪄 Auto-Detect Missing on Load: [ON]" and "🔕 Auto-Detect Missing on Load: [OFF]" to eliminate any semantic confusion regarding the plugin's status.
|
||||
## v1.57.3 Beta (Hotfix: Offline Scan, Prompt Workshop) — 2026-09-29
|
||||
|
||||
### 🔌 Offline scan and small fixes (离线扫描与小修复)
|
||||
- **Offline scan stays offline (离线扫描不再联网)**: "Offline fast read" no longer sends the greyed-out rename and overwrite options, and an offline scan never fetches Civitai model pages or preview images, even for models whose `.info` came from Civitai. Scan requests without options are refused instead of starting an online scan.
|
||||
- Clicking the drop zone at the end of the Prompt Workshop's assembly track focuses the card search again instead of failing silently.
|
||||
- Malformed requests to translation, deletion, scanning and image-material routes get a clear 400 instead of a server error.
|
||||
|
||||
## v1.4.2 (Language Sync & Gallery Patch)
|
||||
### 🚀 Enhancements
|
||||
- **ComfyUI Manager Ready**: The plugin is officially available in the ComfyUI Manager default node list!
|
||||
## v1.57.2 Beta (Hotfix: Canvas Performance, Saving Workflows) — 2026-09-27
|
||||
|
||||
### 💾 Saving workflows and Prompt Notes (保存工作流与提示词笔记)
|
||||
- **Saving a workflow works again (保存工作流失败)**: since v1.57, **Save Current Workflow** failed with "Could not save this Workflow Recipe" for any workflow with a model loader, because splitting the backend into smaller files left the save path calling a function that was no longer imported. The same split also made model cards load full-size cover images instead of thumbnails (slow on big folders), and made editing details of a model that had moved fail.
|
||||
- **Prompt Notes with UNet models (提示词笔记与 UNet 模型)**: sending a note with a UNet / diffusion model to the canvas built a graph that could not run, because its LoRAs waited for a CLIP the UNet does not have. LoRAs of a UNet now load model-only, and the note says to connect your own text encoder to the two prompt nodes. Models whose metadata names no base model (most models not downloaded from Civitai) were missing from every group; they now appear under **No base model listed**. Listing models no longer blocks ComfyUI while it reads the folders.
|
||||
- Installed v1.57.2 before 2026-09-27 through the version panel? Pick v1.57.2 there once more to get this build; updating through ComfyUI Manager gets it as usual.
|
||||
|
||||
### ⚡ Smooth canvas dragging again (画布拖动卡顿修复)
|
||||
- **Dragging and zooming the canvas no longer slows down with this plugin installed (安装插件后拖动画布掉帧)**: one stylesheet rule, which only hid the sidebar tooltip while the Toolbox or Settings popup was open, made the browser re-check the whole page every time any element's inline style changed. ComfyUI moves every text-box widget on every frame while you pan or zoom, so each frame paid for it, even with the browser closed. Measured on a 300-node workflow in ComfyUI: style work while panning drops from about 11× the no-plugin cost back to the same as without the plugin.
|
||||
- **Large workflows with ComfyUI Prompt Assistant (与提示词小助手同时安装时的严重卡顿)**: the cost grew with the number of page elements, and Prompt Assistant adds a row of buttons to every text box, so together style work took about 19 ms per frame (more than a whole 60 fps frame): single-digit frame rates or freezes on big workflows. It is now back to the level of Prompt Assistant alone. The two plugins never conflicted otherwise.
|
||||
- The tooltip still stays hidden while the Toolbox or Settings popup is open; the popups already hide it when they open and it does not reappear until they close.
|
||||
|
||||
### 🧩 Material Library with a node selected (选中节点时素材库卡片错位)
|
||||
- **One apply button per card (每张卡片只有一个应用按钮)**: with a node selected, the Material Library showed every card's **Apply Scheme** button twice, each with a doubled ⚡ icon.
|
||||
- **No more overlapping text (卡片文字不再重叠)**: the extra button squeezed the badges, date, node types and tags on top of each other. Cards in apply mode now get a taller row with the button at the bottom; applying works as before.
|
||||
|
||||
## v1.57.1 Beta (Version Panel: Update, Switch & Roll Back) — 2026-09-23
|
||||
|
||||
### 🏷️ Version & Updates Panel (版本号与一键更新 / 版本切换)
|
||||
- **Version entry in the update guide (更新引导顶部的版本号与更新入口)**: the installed version (e.g. `v1.57.1-beta`) and a **Check for updates / switch version** button now appear at the top of the update guide (opened via the header **!** button), avoiding accidental clicks near the tool dock or crowding the header in docked/scaled layouts. Reading it is local only; nothing goes online.
|
||||
- **Manual "Check for updates" (手动检查更新)**: the plugin never checks by itself. Clicking **Check for updates / switch version** opens the panel, and only the **Check for updates** button contacts GitHub. Each published release shows its notes, date and a GitHub link; pre-releases are hidden by default and are never offered as the latest version.
|
||||
- **One-click update to published releases (一键更新到正式发布版本)**: updates only target releases published on GitHub, not in-progress commits, so what you install is a released build.
|
||||
- **Switch or roll back (切换 / 回退版本)**: any published release can be installed from the list. Rolling back warns that older versions may not read settings or recipes saved by newer ones; nothing is deleted, and it all works again after returning.
|
||||
- **Back to latest & undo (回到最新版 / 撤销上次切换)**: **Back to latest** returns to the default branch and pulls the newest code; **Undo last switch** returns to exactly where you were before. Versions older than v1.57.1 have no panel, so from those use **Update** for this plugin in ComfyUI Manager to come back.
|
||||
- **Safe by default (安全保护)**: every switch is refused while plugin files have local modifications (the files are listed), and a local branch with unpublished commits is never overwritten.
|
||||
- **Restart (重启生效)**: after a switch the panel offers to restart ComfyUI through ComfyUI Manager; without Manager it asks you to restart by hand.
|
||||
|
||||
## v1.57.0 Beta (Toolbox Architecture, Gallery Search & Polymorphic Drag) — 2026-09-23
|
||||
|
||||
### 🎨 Pure Vector Iconography Overhaul (纯代码矢量图标体系全面升级)
|
||||
- **Universal Code-Native SVG Icons (全套规范化纯代码矢量 SVG 图标)**:
|
||||
- Completely phased out legacy emojis and pixel icons in favor of lightweight, mathematically crisp 24x24 code-native vector SVGs (`stroke-width: 2`, rounded joins/caps).
|
||||
- Redesigned the floating entry trigger orb with the **Anomalous Hypercube Core** (`1b985ba`) and frosted glass lighting effect, maintaining smooth rendering and subtle hover micro-interactions.
|
||||
- Sidebar and toolbox action buttons upgraded with distinct vector geometry: 3D Asset Cube for Toolbox, Radar Pulse for Scan Wizard, Stethoscope for Model Doctor, Four-Point Spark for Node Assistant, Layered Sheets for Material Library, and Obsidian Gear for Settings.
|
||||
- Eliminated icon flicker, CSS pseudo-element clipping, and alignment shifts across all display scales.
|
||||
|
||||
### 🔍 Multi-Dimensional Gallery Search (历史出图图库多维检索与标签块)
|
||||
- **Multi-Field Real-Time Search (提示词/模型/种子/哈希多维检索)**:
|
||||
- Upgraded the native output gallery header with an intelligent search bar capable of parsing prompts, model file names, exact numeric seeds, and cryptographic SHA-256 model hashes simultaneously (`1476faf`).
|
||||
- Search terms are dynamically converted into visually distinct, standalone **Search Blocks (搜索标签块)** (`e077689`).
|
||||
- Each search block clearly displays its matched category and provides an accessible `×` button for one-click removal, enabling flexible compound filtering without retyping.
|
||||
- Backed by optimized chunk parsing (`api/image_search.py`) that reads only header metadata prior to pixel buffers, ensuring zero-lag gallery navigation.
|
||||
|
||||
### 🚀 Polymorphic Canvas Drag & Drop (全域多态画布拖拽架构)
|
||||
- **Universal Canvas Drag (全域多态画布拖拽)**:
|
||||
- Dragging workflow materials onto empty canvas automatically reconstructs and opens the full workflow.
|
||||
- Dragging prompt-only materials onto empty canvas auto-creates native `CLIPTextEncode` nodes with pure prompt text, bilingual titles, and standard ComfyUI dark theme colors (`#532323` for negative / `#235327` for positive).
|
||||
- Dragging materials onto existing canvas nodes prioritizes the target node, injecting matching widget values and prompt text via semantic widget sniffing while strictly filtering out model file paths and loader blocks.
|
||||
- **Floating Trigger Pre-Mount & Anti-FOUC (悬浮入口预挂载防闪烁与安全吸附)**:
|
||||
- Completely eliminated FOUC flicker on page refresh via pre-mount positioning and smooth opacity reveal (`53c9f1b`).
|
||||
- Hardened PointerEvents persistence, safe coordinate validation, and boundary clamping to prevent trigger entrapment or collapsing to the viewport origin (`09b06d1`).
|
||||
|
||||
### 🧰 Toolbox Catalog & Dedicated Bottom Dock (实用工具箱与底部固定快捷栏架构)
|
||||
- **Dedicated Bottom Action Bar (结构化底部快捷栏)**:
|
||||
- Streamlined the lower-left sidebar into a dedicated 6-button utility bar:
|
||||
1. **Toolbox (🧰 实用工具箱)**: Anchored as the primary launcher on the left to access secondary tools.
|
||||
2. **Scan Wizard (🔄 扫描向导)**: Direct shortcut to launch model library scanning.
|
||||
3. **Model Doctor (🩺 模型医生)**: Direct shortcut to diagnose and auto-heal missing workflow models.
|
||||
4. **Node Assistant (🤖 节点助手)**: Direct shortcut for canvas node inspection, model swapping, and parameter injection.
|
||||
5. **Material Library (✨ 素材库)**: Direct shortcut to manage curated material bundles and polymorphic canvas drag-and-drop.
|
||||
6. **Settings (⚙️ 设置)**: Fixed anchor on the right for interface preferences.
|
||||
- **Toolbox Modal Catalog (实用工具箱浮窗面板)**:
|
||||
- Clicking the first icon (**🧰 实用工具箱**) opens a dedicated popup panel housing 5 secondary tools not on the main dock:
|
||||
1. **Workflow Transfer Center (导入导出)**: Lossless AMB format workflow share code import and export.
|
||||
2. **Prompt Studio (提示词工坊)**: Modular prompt mixer deck for lego-block prompt extraction, reordering, and assembly.
|
||||
3. **Prompt Translator (翻译助手)**: Bilingual real-time English/Chinese prompt translation.
|
||||
4. **Model Source Hub (模型来源)**: Online model source URL inspector with instant canvas Note node generation.
|
||||
5. **Prompt Notes (提示词笔记)**: Lightweight notebook for drafting and organizing reusable prompt snippets (`workflows/anomalous_notebooks`).
|
||||
- **Enhanced Sidebar Tooltips & Reveal Timing (侧边栏操作提示与动画优化)**:
|
||||
- 100ms quick hover text replacement on shortcut buttons and 600ms rich singleton floating tooltip bubble (`#anomalous-sidebar-tooltip-bubble`) with bold title and muted explanation, cleanly avoiding CSS pseudo-element clipping issues.
|
||||
- Clicking or pressing any action button immediately suppresses hover text and floating tooltips.
|
||||
- Maintained elegant obsidian glass / dark monochromatic aesthetics without harsh or distracting bright colors.
|
||||
|
||||
### Navigation and update guide
|
||||
- Sidebar icons reveal compact text labels on hover or keyboard focus while keeping their existing button size and actions. Detailed hints explain each tool; scan-state and language changes retain labels. Unclipped tooltips ensure explanations are fully readable.
|
||||
- Update guide opens only from the persistent top-right exclamation button or Help. Dismissing never removes the entry. Sidebar labels slide upward on hover/focus; duplicate source-panel and obsolete notice styles were removed. See `docs/architecture/update-guide.md`.
|
||||
|
||||
### Model Source Hub (模型来源中控中心)
|
||||
- Added a unified Model Source Hub under Toolbox for dual-scope (current workflow and full library) model source URL inspection, one-click browser navigation to Civitai/HuggingFace/Liblib/ModelScope, custom source editing, workflow metadata synchronization (`extra.anomalous_model_sources`), and instant canvas `Note` node generation for non-intrusive workflow documentation.
|
||||
|
||||
### Export availability
|
||||
- Removed Prompt Studio's JSON export action and closed the Material Library file-import entry; copying prompts and local saves remain available.
|
||||
- Recipe package import/export is disabled in the UI and API, including both upload inspection and import commit. The verified AMB0/AMB1 workflow Import / Export Center is restored in Toolbox as the only active import/export center. Ordinary downloads and image/workflow hash metadata are unchanged.
|
||||
- Studio source cards automatically follow library additions, edits and deletions, display their origin, and cannot be deleted from the studio. Failed synchronization preserves existing cards; mixer copies remain editable.
|
||||
|
||||
### Material Library
|
||||
- Fixed grouped material filters, Recipe scope/tag filters, and prompt studio imports using actual detail payloads. Fixed side-studio draft reset/return state and guarded recipe drops against canvas changes. Recipe model status no longer treats saved hash verification as current availability.
|
||||
- Separated the lower-left Material Library from the Recipes/Prompt Notes workspace. Added canvas drag-and-drop: parameter materials replace matching node values, while the simplified prompt panel inserts text at the beginning or end, with undo.
|
||||
- Material Library now opens from the lower-left control; Workflow Recipes stays at the upper right. The workflow Import / Export Center is available in Toolbox.
|
||||
- Added Prompt Combinations: ordered general/specific fragments, enable switches, separate positive/negative content, and saved snapshots.
|
||||
- Library cards can apply matching parameters to one selected node through the same transaction as Node Assistant, with seed preservation, native-option validation and guarded undo. Prompt combinations support explicit text replacement or append.
|
||||
- Material details initially expand prompt cards only; other node parameters render on first expansion. Details omit the complete source workflow, which loads separately when opening a full snapshot. Selected-node materials never return their hidden source graph.
|
||||
- Material capture supports PNG workflows, Recipe parameters and Prompt Notes. Applying reference images to LoadImage remains future work.
|
||||
- Added search by name, tag, or node type, type/tag filters, and pagination. Material names and tags can be edited in place without changing their workflow.
|
||||
- Saving the same source image and node selection now prompts before creating another copy. Save labels distinguish full image/workflow snapshots, node parameters, and Prompt Notes.
|
||||
- Shared parameter inspection between the library and image workbench, improved keyboard card access and clipboard failure feedback, and fixed confirmation dialogs being covered by the workbench.
|
||||
|
||||
### Reliability
|
||||
- Material browsing reuses signature-validated summaries and reads full workflows only for matching node types. Gallery pagination shares a short-lived directory snapshot; refresh and deletion invalidate it immediately.
|
||||
- Prompt Notes now use the ComfyUI user directory with recoverable legacy copying, atomic saves, and visible save failures. Material saves stage their files and roll back new assets if persistence fails.
|
||||
- File identity now uses full-file SHA-256 only. BLAKE3 and tensor/header digests cannot become verified file identities. Older offline metadata is reverified on demand while preserving personal notes.
|
||||
|
||||
### 🩺 Model Doctor
|
||||
- **Manual size fallback (模型大小手动兜底)**: A unique same-size local model is now presented for explicit confirmation instead of being applied automatically. Background checks never apply it, while hash/size identity conflicts are rejected and shown in the Doctor dashboard.
|
||||
|
||||
### 🧰 Workflow Recipes
|
||||
- **Scope-aware recipes (局部/整体配方)**: Graphs with open required connections or no runnable output are saved as partial recipes and append to the current canvas. Independently runnable graphs are saved as complete recipes and open in a new workflow canvas.
|
||||
- **Optional save-time model verification (保存时可选模型核验)**: Recipe saving now performs a lightweight cached-identity check. Recognized model references missing identity are listed in the save dialog, while full SHA-256 calculation remains explicitly opt-in. This is especially useful for hash-only Model Doctor categories such as VAE and text encoders.
|
||||
- **Model lifecycle consistency (模型生命周期一致性)**: Partial append remaps and merges node-scoped model Hash records, while applying a local model match updates the workflow value, structured model reference, base-model summary, and Hash index together.
|
||||
- **Recipe-scoped model notes (配方模型备注)**: Each model reference can keep a bounded personal note. Package export asks whether to include these notes, and version comparison reports note and identity changes.
|
||||
- **Material Library MVP (统一素材库 MVP)**: Main Gallery and Recipe gallery images now expose a non-intrusive two-column parameter detail view and can be saved as one curated bundle containing the original image, exact embedded workflow, model provenance, and reusable node blocks. Core metrics remain concise, while a lazy two-level inspector exposes every serialized node widget value, node properties, mode, and volatile-parameter marker on demand. Every node card now has a direct “Save to Library” action; checked nodes share a colocated multi-save action above the list, while the footer is reserved for the full image/workflow snapshot. One generation-settings action saves the sampler and size nodes together, and prompt cards expose topology-based positive/negative detection, manual role overrides, and direct node saving. Saved Material details preserve those overrides and allow returning to automatic detection. Workflow Recipe parameters and saved Parameter Notebooks can now publish all widget-bearing nodes, one node, or an explicit node selection as image-free Recipe Parameter materials. The ambiguous plain-text share action has been removed in favor of a visible Material Library save entry. Focused node materials feed Node Assistant without exposing a misleading full-workflow action. Saved Material cards stay compact and open a focused in-library inspector with the uncropped reference image on the left and scoped model/node parameters on the right; returning releases the full response and detailed DOM. Detail navigation bounds parsed metadata to a 16-entry LRU, cancels obsolete reads, avoids a second full-PNG browser allocation, removes duplicated widget payloads, and defers local-model preview resolution until the Models tab opens. Main Gallery keeps its existing click, drag, delete, and cover-selection behavior. The Workspace Material Library opens complete snapshots in a new canvas, while Node Assistant filters matching blocks by the selected node type, skips volatile seeds, and hands remapped Hash evidence to Model Doctor.
|
||||
|
||||
### ✨ Material capture and simpler pages
|
||||
- 图片、配方参数与提示词笔记均可保存到素材库,保存后直接查看。提示词笔记支持整份组合或仅提示词,并可从素材载入为新笔记。
|
||||
- 图片保存表单、用途调整、节点技术信息、笔记模型区与配方多选改为按需展开;配方参数页去掉重复数值,统一使用“参数方案”名称。
|
||||
|
||||
### ✨ Interface & Access
|
||||
- **Native Configurable Shortcut (原生可配置快捷键)**: Added `Ctrl + Shift + M` as the default shortcut for opening the browser. The plugin's Interface settings show the currently active binding and open ComfyUI's native key recorder for customization. Conflict detection, overwrite confirmation, removal, reserved-key guidance, and modal guards therefore remain centralized without a separate global keyboard listener.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Native Language Sync**: Fixed a bug where the plugin would not properly synchronize its UI language with ComfyUI's modern native `Comfy.Locale` settings. The language check has been migrated from local storage caching to a dynamic `app.ui.settings` API call during the extension setup phase.
|
||||
- **Gallery Zoom Review**: Verified and confirmed that the mouse wheel zoom feature inside the gallery image viewer is functioning natively as intended.
|
||||
## v1.4.1 (The Drag & Drop Resilience Update)
|
||||
### 🚀 Enhancements
|
||||
- **Silent Quick Scan Button**: Added a new dedicated `🔍 Check & Auto-Scan Missing Info` button to the Settings Hub. It silently finds unscanned models and extracts their hashes in the background without popping up the intrusive Civitai black-box prompt, keeping your workflow uninterrupted.
|
||||
- **Hires Fix Drag Resilience**: Fixed a critical Chromium memory allocation crash where dragging extremely large (Hires. fix) images from the Gallery would silently fail to initiate. Implemented a lightweight, translucent SVG "Workflow" ghost using `setDragImage`, allowing massive 4K+ images to be dragged seamlessly onto the ComfyUI canvas without memory limits.
|
||||
- **Operation Guide**: Added a highly requested `docs/核心按钮操作指南_大白话版.md` (Layman's Core Operation Guide) to clearly distinguish between the different scanning and fixing buttons for new users.
|
||||
- **Prompt Studio Preview Popover Safe Zone (提示词预览浮窗全域安全区与防消失机制)**: 解决鼠标从词卡划向右侧浮窗按钮过程中浮窗提前消失的问题。引入全局动态指针安全区判定(`isPointInSafeZone`),将卡片、浮窗(向外各扩展 40px)与空间通道全部纳入安全驻留区,光标移动即实时取消关闭计时;并在定时器回调中执行终审坐标校验;结合 100ms 展开防抖与 `[📌 固定]` 钉住功能,确保用户可从容点击复制与添加按钮。
|
||||
- **Live Language Switching (语言即时切换)**: Changing the plugin language now immediately re-translates the open native Interface settings—including labels, tooltips, and combo choices—as well as the browser UI, without refreshing ComfyUI.
|
||||
|
||||
## v1.56.1 Beta (Interface Preferences & Scan Reliability) — 2026-08-26
|
||||
|
||||
### ✨ Interface & Access
|
||||
- **Native Interface Preferences (原生界面偏好)**: Added one native ComfyUI entry-mode setting with three strictly exclusive choices: floating button, action-bar button beside the run controls, or Extensions menu only. Floating size/style controls now appear only when floating mode is selected. A colocated language preference follows ComfyUI by default or applies a plugin-only Chinese/English override, and the existing in-browser language shortcut updates that same native setting. Entry selection remains authoritative during action-bar redraws, and every entry reuses the same browser instance and open path.
|
||||
|
||||
### 🐛 Reliability & Scan Feedback
|
||||
- **Interrupted Scan Recovery (异常扫描自动恢复)**: Closing ComfyUI during a folder or global scan no longer leaves the browser permanently locked. Versioned scan ownership records distinguish live workers from stale or legacy markers, clean interrupted state automatically, and allow an immediate retry without reinstalling the extension.
|
||||
- **Real Scan Progress (真实扫描进度)**: Folder, custom-selection, and global scans now show a non-blocking localized progress panel backed by the scanner's actual folder/model counts and current filename. Reopening the browser reconnects to the running task through the existing status polling contract.
|
||||
|
||||
## v1.56 Beta (Model Doctor Intelligence & Smart Civitai Routing)
|
||||
|
||||
### 🚀 Major Improvements & Features
|
||||
- **On-Demand Dynamic Hash Resolution (按需动态算哈希与智能认亲)**: Upgraded Model Doctor's fallback resolution engine. When a local candidate model matches the exact byte size of a missing reference but lacks local metadata (e.g., after renaming and deleting `.info` files), the backend dynamically calculates its SHA256 on demand. Upon cryptographic verification, the node is seamlessly resolved and an offline inferred `.info` record is saved locally.
|
||||
- **Dedicated Hash Inspection Modal (🔑 查看哈希透视详情)**: Added a dedicated `🔑 查看哈希 (View Hash)` button on every model card in the Doctor Dashboard. Clicking it opens a crystal-clear inspection modal comparing the workflow-embedded provenance hash & size with local disk hash & size, featuring one-click clipboard copying.
|
||||
- **Smart Civitai by-hash Routing & NSFW Domain Dispatch (C 站精准直达与 .red/.com 智能分流)**: Upgraded the "🌐 Civitai" action buttons across Model Doctor and Workflow Import. Instead of crude search bar queries, it now queries the official Civitai `by-hash` API to resolve exact Model IDs and Version IDs, automatically routing NSFW models to `civitai.red` and safe models to `civitai.com`.
|
||||
- **UNET Default Scan Coverage (UNET 目录扫描补齐)**: Added `models/unet` to the default scanned model types, ensuring UNET / Diffusion Models are consistently indexed during background deep scans.
|
||||
- **Parameter Notebook Renaming (参数笔记本重命名)**: Users can now rename saved parameter notebooks directly from both the recipe sidebar list and the detail heading via inline `✏️` action buttons and modal prompts, safely updating names with atomic writes without disrupting parameter provenance or canvas matching.
|
||||
- **Cross-Platform Path Separator Normalization (跨平台斜杠全链路归一化)**: Fully unified Windows `\` and Unix `/` path handling across workflow serialization, hash retrieval, and node diagnostic matching, preventing lookup misses caused by cross-platform workflow exchange.
|
||||
|
||||
### 🐛 Bug Fixes & UX Polish
|
||||
- Fixed a false-negative rejection issue where size-matching renamed models without local `.info` files were incorrectly rejected as `identity_conflict`.
|
||||
- Fixed duplicate key icons on the Model Doctor Hash Inspection modal header.
|
||||
- Fixed a loop duplication issue in the workflow import modal preflight inspection.
|
||||
- Updated unit test suite with comprehensive coverage for dynamic hash resolution and unet folder discovery.
|
||||
|
||||
## v1.55 Beta (Workflow Recipes Preview)
|
||||
|
||||
> **Beta scope:** Workflow Recipes, Parameter Notebooks, and the recipe-powered Parameter Presets tab in Node Assistant are preview features. The Node Assistant's Actions tab—visual model replacement and LoRA insertion—is not part of this beta. Save the current canvas and back up `workflows/anomalous_recipes` plus `workflows/anomalous_parameters` before updating, importing, restoring, deleting, or applying recipe data. The stable target for these preview features is v1.6.
|
||||
|
||||
### 🚀 Major Features
|
||||
- **Workflow Recipes (工作流配方)**: Save a complete workflow with a cover, name, tags, notes, model references, prompts, sampler settings, safe node parameters, and a reproducibility fingerprint. Recipes append to the current canvas without replacing it.
|
||||
- **Recipe Detail Workspace (配方详情)**: Added Overview, Parameter Notebook, Gallery, and Versions tabs. Long values can be expanded or copied, model requirements are shown as separate cards, and the overview avoids exposing local model paths as display names.
|
||||
- **Parameter Notebooks (参数笔记本)**: Create edited parameter notes, capture a fresh note from a matching live canvas, switch between saved notes with clear feedback, delete individual notes, and apply one note back to a workflow only after the complete recipe skeleton matches. Volatile values such as seeds are intentionally ignored.
|
||||
- **Node Assistant Recipe Presets (节点助手配方预设)**: The Parameter Presets tab reads same-type node values saved by Workflow Recipes. Select a node such as KSampler and apply sampler, scheduler, steps, CFG, denoise, and other safe values in one click without replacing the rest of the workflow.
|
||||
- **Model Identity and Local Matching (模型身份与本地匹配)**: Recipe model cards retain hashes, size, model category, available Civitai origin names/links, and previews. Imported recipes can match another author's model references to local files without trusting their folder paths.
|
||||
- **Recipe Gallery and Parameter Comparison (配方图库与参数对比)**: Find historical outputs with the same node structure, inspect embedded generation data, compare differing parameters, and promote an output image to the recipe cover.
|
||||
- **Version History (版本历史)**: Recipe updates retain bounded local history. Compare meaningful changes, restore an older version safely, and preserve the current version before restoration.
|
||||
- **Portable Recipe Packages (便携配方包)**: Import or export recipes with optional preview snapshots and history. Model identity hashes can be removed for privacy. Preview snapshots contain presentation images only and never include model files.
|
||||
- **Conservative Prompt Roles (保守的正负提示词识别)**: Native supported prompt paths are classified without title guessing. Unknown third-party text nodes remain unclassified until the user labels them as positive, negative, shared, or ignored.
|
||||
|
||||
### ✨ Interface and Interaction
|
||||
- Renamed the combined Notes and Recipes area to **Creative Workspace / 创作工作台**, with separate Prompt Notes and Workflow Recipes sections.
|
||||
- Recipe models, LoRAs, prompt text, common parameters, and generic node controls use dedicated responsive layouts with copy and full-content expansion where needed.
|
||||
- Returning from a model detail page restores the previous recipe position instead of jumping back to the top.
|
||||
- The main output gallery refreshes when opened, avoiding continuous background polling.
|
||||
- Beta and data-protection guidance is available in the Recipe list, the Node Assistant Parameter Presets tab, Settings Help, and README in both Chinese and English.
|
||||
|
||||
### 🐛 Reliability Fixes
|
||||
- Fixed a cross-panel state leak where opening a model from a Recipe Overview could overlap Node Assistant, then leave the next Workflow Recipe visit with an empty list. Returning normally, switching directly to another main panel, reopening Workspace, and closing Workspace mid-return now all restore one coherent surface.
|
||||
- Fixed blank Workspace/Parameter Notebook surfaces and restored the plugin trigger when frontend module errors prevented registration.
|
||||
- Fixed parameter application against current ComfyUI widget callbacks and added transactional rollback when a node update fails.
|
||||
- Fixed positive/negative prompt duplication and legacy role recovery while keeping ambiguous third-party prompt nodes visibly unknown.
|
||||
- Fixed panel closing and canvas handoff behavior, docked notebook confirmation controls, recipe scroll restoration, long model status text, and narrow parameter layouts.
|
||||
- Added backend validation and regression coverage for recipe round-tripping, linked inputs, parameter-note deletion, model matching, and prompt-role metadata.
|
||||
|
||||
## v1.5.1 (The Workflow Exchange & Deep Scan Update)
|
||||
### 🚀 Major Features
|
||||
- **Lossless Workflow Exchange (无损工作流交互)**: Added dedicated Import/Export buttons to the bottom-left of the plugin UI. This feature allows users to directly extract, share, and import pure workflow data without relying on original PNG images, completely bypassing the metadata-stripping compression used by social platforms (like WeChat or Discord) that causes workflow loss.
|
||||
- **Force Overwrite Configs (强制覆盖已有配置)**: Added a new "Force Overwrite" toggle in the Scan Wizard. When enabled, the scanner will deliberately ignore existing local `.info` files and force a fresh SHA256 hash calculation and a complete metadata pull from Civitai. This is a lifesaver for repairing incorrectly matched or corrupted model profiles.
|
||||
- **Model Card Settings (模型卡片设置)**: The Settings Hub now opens one focused panel for video-cover playback (`Always play` or recommended `Play on hover`) and card image quality (`Optimized thumbnail` or `Original cover`). Existing autoplay preferences remain compatible, while image-quality choice is intentionally simple and local to the browser.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Orange Toast Eradication**: Completely ripped out the overly aggressive `app.queuePrompt` interceptor. Users will no longer be bombarded by the orange "Unscanned Models" warning toast every time they click Generate.
|
||||
- **Base Model Hash Misattribution**: Fixed a critical bug in the fast scanner where it would aggressively fuzzy-match any 64-character hex string in the metadata. This previously caused LoRAs (trained via Kohya) to inherit their Base Model's hash (`ss_sd_model_hash`), resulting in totally incorrect Civitai mappings. The scanner now strictly adheres to the official `modelspec.hash.sha256/blake3` standard.
|
||||
- **Lossless Cover Reset**: Reset now restores the persistent `.civitai_bak.*` copy first, falls back to a bare original cover for non-Civitai models, and preserves the active preview with a visible warning when no recoverable source exists.
|
||||
- **Safe Sidecar Lifecycle**: Deleting a model cleans recognized sidecars, while renaming migrates them—including the Civitai backup—to the new stem. Same-stem model files with different model extensions are never treated as sidecar garbage.
|
||||
- **Bounded Sidecar I/O**: Repeated extension literals in the API and scanner were replaced with fixed tuples and exact-path checks. Rename, delete, and reset remain constant-bounded and never scan an entire model directory.
|
||||
- **Reset/Rename Extension Integrity**: Fixed a reused loop variable that could change a model's extension when cover reset and physical rename were requested together.
|
||||
- **Signature-Aware Metadata Cache**: Unchanged model metadata and embedded safetensors hashes are now served from bounded caches keyed by physical file signatures. Sidecar or model changes invalidate entries automatically, while defensive copies prevent cache pollution.
|
||||
- **Stable Cover Caching**: Removed per-refresh random cover URLs and switched to nanosecond file-version tokens, allowing the browser to reuse unchanged images and videos while still updating changed covers immediately.
|
||||
- **Responsive Large Libraries**: Folder/model disk work now runs outside the aiohttp event loop, current-folder discovery uses one `os.scandir()` inventory, stale requests are aborted, and cards render in animation-frame chunks with lazy media activation.
|
||||
- **Batched Model Doctor Resolution**: Provenance-rich workflows skip a redundant full hash-cache walk, then resolve missing nodes through a type-grouped batch endpoint so each required category is walked once per repair pass. Legacy workflows still refresh the filename cache when needed. The original hash/size/type/ambiguity rules remain unchanged, with automatic fallback to the single-item endpoint.
|
||||
- **Direct Preview Fast Path**: Visual replacement resolves contained relative model paths directly and performs a full library walk only for unresolved basename fallbacks.
|
||||
- **Balanced Cover Memory Use**: Static grid covers default to cached 512px WebP derivatives while detail views retain the original. Derived files are stored in ComfyUI's temporary area, capped at 256 MiB, and never alter source covers. Closing the browser releases active media immediately and releases the warm card DOM after 90 seconds of inactivity.
|
||||
|
||||
## v1.5.0 (The Workflow & UI Evolution Update)
|
||||
### 🚀 Major Features
|
||||
- **Node Assistant (节点助手)**: Introduced a dedicated "Assistant" tab within the Model Doctor. Selecting any node in the canvas instantly focuses the Assistant on that specific node, providing deep analysis and contextual controls without obscuring your view.
|
||||
- **Visual Model Switcher (可视化模型选择)**: Eliminated blind dropdown menus! When replacing a model, a stunning visual grid pops up inside the Assistant panel, displaying full cover images and model names. You can now select replacements purely visually.
|
||||
- **Global Health Dashboard (全图体检报表)**: A top-level dashboard in the Doctor panel provides a bird's-eye view of your entire workflow. It categorizes nodes into "Healthy", "Missing", "Replaced", and "Unrelated", giving you instant situational awareness.
|
||||
- **Inline Node Notes (内联节点备注)**: Added the ability to view and edit model notes directly beneath the preview image in the Node Assistant, ensuring zero layout shifting and maintaining a continuous UX flow.
|
||||
- **Local Metadata Parsing Toggle (非C站模型本地解析)**: Added a new explicit toggle in the Settings Hub. This allows users to enable deep local/offline metadata scanning (e.g. for HuggingFace models) without strictly enforcing a Civitai lookup.
|
||||
|
||||
### 🐛 Bug Fixes & UX Polish
|
||||
- **Regex Replacement Bug**: Fixed a critical bug where regex backslashes were swallowed during update patching, causing a syntax error in `main.js` that made the plugin icon disappear.
|
||||
- **Dynamic Regex Splitting**: Ensured file paths are split properly using both forward slashes and backslashes `/[\/]/` for complete cross-platform Windows/Linux compatibility.
|
||||
- **Temporary Feature Suspension**: Temporarily commented out the WIP "Preflight Import Workflow" button while its parsing engine is being finalized.
|
||||
|
||||
## v1.4.4 (The Cover Decoupling Update)
|
||||
### 🚀 Enhancements
|
||||
- **Custom Cover Decoupling**: Manually selected covers (from the gallery or local uploads) now exclusively use a `.preview.*` suffix. The backend no longer artificially renames standard non-Civitai `.png` files to `.civitai_bak`, preventing semantic entanglement.
|
||||
- **Intuitive Reset Logic**: Clicking 'Reset' now cleanly deletes the custom `.preview.*` file. For non-Civitai models, it organically falls back to your original manual cover. For Civitai models, it accurately restores the `.civitai_bak` file obtained from the initial scan.
|
||||
- **UX Workflow Polish**: After changing a cover via the gallery or local upload, the UI no longer stubbornly bounces you back to the Model Edit modal. It gracefully returns you straight to the model grid to admire the new cover, eliminating the confusing implication that you still needed to click 'Save'.
|
||||
- **Cache Busting for Covers**: Added a timestamp parameter when loading covers into the grid so that uploading a new custom cover instantly updates the display without requiring a manual browser refresh.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Video Cover Regex Fix**: Fixed a bug where `.mp4` and `.webm` video covers failed to play if cache-busting URL parameters were appended. The regex now gracefully handles trailing `&` or `?` parameters.
|
||||
- **State Null-Reference Crash**: Fixed an `undefined` error where a missing `this.models` assignment during the model reload phase caused the UI to abort mid-transition, resulting in a blank screen.
|
||||
|
||||
|
||||
## v1.4.3 (The Silent Load Update)
|
||||
### 🚀 Enhancements
|
||||
- **Silent Loading by Default**: Reversed the default behavior of the hash resolver. To prevent interrupting workflows for users with massive unindexed local libraries, the aggressive "Missing Models Alert" popups and heavy background hash fetching on startup are now **OFF by default**. You can enable the auto-detect feature manually via the Settings Hub.
|
||||
- **Refined Settings UI**: Updated the terminology in the Settings Hub. The toggle has been re-worded from "Auto-Detect Missing: ON/OFF" to "🪄 Auto-Detect Missing on Load: [ON]" and "🔕 Auto-Detect Missing on Load: [OFF]" to eliminate any semantic confusion regarding the plugin's status.
|
||||
|
||||
|
||||
## v1.4.2 (Language Sync & Gallery Patch)
|
||||
### 🚀 Enhancements
|
||||
- **ComfyUI Manager Ready**: The plugin is officially available in the ComfyUI Manager default node list!
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Native Language Sync**: Fixed a bug where the plugin would not properly synchronize its UI language with ComfyUI's modern native `Comfy.Locale` settings. The language check has been migrated from local storage caching to a dynamic `app.ui.settings` API call during the extension setup phase.
|
||||
- **Gallery Zoom Review**: Verified and confirmed that the mouse wheel zoom feature inside the gallery image viewer is functioning natively as intended.
|
||||
## v1.4.1 (The Drag & Drop Resilience Update)
|
||||
### 🚀 Enhancements
|
||||
- **Silent Quick Scan Button**: Added a new dedicated `🔍 Check & Auto-Scan Missing Info` button to the Settings Hub. It silently finds unscanned models and extracts their hashes in the background without popping up the intrusive Civitai black-box prompt, keeping your workflow uninterrupted.
|
||||
- **Hires Fix Drag Resilience**: Fixed a critical Chromium memory allocation crash where dragging extremely large (Hires. fix) images from the Gallery would silently fail to initiate. Implemented a lightweight, translucent SVG "Workflow" ghost using `setDragImage`, allowing massive 4K+ images to be dragged seamlessly onto the ComfyUI canvas without memory limits.
|
||||
- **Operation Guide**: Added a highly requested `docs/核心按钮操作指南_大白话版.md` (Layman's Core Operation Guide) to clearly distinguish between the different scanning and fixing buttons for new users.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Orange Toast Eradication**: Completely ripped out the overly aggressive `app.queuePrompt` interceptor. Users will no longer be bombarded by the orange "Unscanned Models" warning toast every time they click Generate.
|
||||
- **CSS Compatibility**: Added the standard `line-clamp` property alongside `-webkit-line-clamp` in the stylesheet for better cross-browser compatibility when truncating model names.
|
||||
|
||||
## v1.4.0 (The UI & Architecture Overhaul)
|
||||
### 💎 Gemini-Style Popovers & UX
|
||||
- **Lightweight Side Popovers**: Completely dismantled the heavy, center-screen settings modal with blurred backgrounds. Rebuilt the Settings Hub as a lightweight, non-intrusive side-popover that snaps to the sidebar—heavily inspired by Gemini's web UI.
|
||||
- **Global Click-Outside**: Implemented a zero-leak Vanilla JS global `mousedown` listener. Clicking anywhere outside an active popover instantly and smoothly dismisses it, vastly improving workflow immersion.
|
||||
- **Full-Width Action Dock**: Replaced the cramped bottom capsule with a 100% width solid Action Dock. This provides a stable visual foundation and properly balances the action buttons (Scan, Fix) against the Settings cog.
|
||||
- **Gallery Letterboxing**: Switched the Image Gallery's thumbnail rendering from `object-fit: cover` to `object-fit: contain` with a deep `#000` background. Tall 9:16 vertical character portraits are now 100% visible, perfectly framed by natural letterboxing instead of being decapitated.
|
||||
|
||||
### ⚙️ Deep Architecture & Bounds Fixing
|
||||
- **Banned CSS `zoom`**: Eradicated the CSS `zoom` property for UI scaling, which was severely distorting the browser's physical bounding boxes and causing the plugin to randomly "fly off-screen" when dragged to the top edge.
|
||||
- **Rem/Calc Sizing Engine**: Adopted a bottom-up relative scaling approach using `font-size: calc(16px * scale)`. The plugin container maintains stable physical dimensions while internals scale smoothly, restoring absolute precision to the drag-and-drop collision physics.
|
||||
- **State Mutex Locks**: Implemented strict interaction locks. Entering an exclusive fullscreen view (like the Gallery) now aggressively disables the main hamburger menu to prevent rendering conflicts and DOM state overlapping.
|
||||
- **Centralized i18n Mutator Engine**: Solved the "Fake Localization" bug where newly added dynamic buttons remained in Chinese. All persistent DOM elements are now strictly bound to the Top-Down language mutation hook, ensuring flawless 1-click bilingual swapping without Vue or React.
|
||||
|
||||
## v1.4.0 (The UI & Architecture Overhaul)
|
||||
### 💎 Gemini-Style Popovers & UX
|
||||
- **Lightweight Side Popovers**: Completely dismantled the heavy, center-screen settings modal with blurred backgrounds. Rebuilt the Settings Hub as a lightweight, non-intrusive side-popover that snaps to the sidebar—heavily inspired by Gemini's web UI.
|
||||
- **Global Click-Outside**: Implemented a zero-leak Vanilla JS global `mousedown` listener. Clicking anywhere outside an active popover instantly and smoothly dismisses it, vastly improving workflow immersion.
|
||||
- **Full-Width Action Dock**: Replaced the cramped bottom capsule with a 100% width solid Action Dock. This provides a stable visual foundation and properly balances the action buttons (Scan, Fix) against the Settings cog.
|
||||
- **Gallery Letterboxing**: Switched the Image Gallery's thumbnail rendering from `object-fit: cover` to `object-fit: contain` with a deep `#000` background. Tall 9:16 vertical character portraits are now 100% visible, perfectly framed by natural letterboxing instead of being decapitated.
|
||||
|
||||
### ⚙️ Deep Architecture & Bounds Fixing
|
||||
- **Banned CSS `zoom`**: Eradicated the CSS `zoom` property for UI scaling, which was severely distorting the browser's physical bounding boxes and causing the plugin to randomly "fly off-screen" when dragged to the top edge.
|
||||
- **Rem/Calc Sizing Engine**: Adopted a bottom-up relative scaling approach using `font-size: calc(16px * scale)`. The plugin container maintains stable physical dimensions while internals scale smoothly, restoring absolute precision to the drag-and-drop collision physics.
|
||||
- **State Mutex Locks**: Implemented strict interaction locks. Entering an exclusive fullscreen view (like the Gallery) now aggressively disables the main hamburger menu to prevent rendering conflicts and DOM state overlapping.
|
||||
- **Centralized i18n Mutator Engine**: Solved the "Fake Localization" bug where newly added dynamic buttons remained in Chinese. All persistent DOM elements are now strictly bound to the Top-Down language mutation hook, ensuring flawless 1-click bilingual swapping without Vue or React.
|
||||
|
||||
## v1.3.0 (Zero-API Tensor Fingerprinting Update)
|
||||
### 🧠 HuggingFace Native Support
|
||||
- **Offline Base Model Inference**: Added a zero-API local inference engine! For models downloaded purely from HuggingFace (or private unreleased models) that return a 404 on Civitai, the scanner no longer gives up. It now forcibly parses the .safetensors structure and uses **Tensor Fingerprinting** (e.g., detecting double_blocks.0.img_attn for Flux) to accurately deduce the underlying base architecture with 100% precision.
|
||||
- **Universal UI Integration**: Successfully inferred offline models are dynamically assigned a virtual .info payload (ID: -1). This instantly grants them full VIP access to the frontend ecosystem—they seamlessly appear in the Cross-Folder Radar, interact perfectly with the bilingual Notebook, and support one-click Auto-Inject loaders, all completely completely offline!
|
||||
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## v2.0.0 (The Workflow & UI Evolution Update)
|
||||
### 🚀 Major Features
|
||||
- **Node Assistant (节点助手)**: Introduced a dedicated "Assistant" tab within the Model Doctor. Selecting any node in the canvas instantly focuses the Assistant on that specific node, providing deep analysis and contextual controls without obscuring your view.
|
||||
- **Visual Model Switcher (可视化模型选择)**: Eliminated blind dropdown menus! When replacing a model, a stunning visual grid pops up inside the Assistant panel, displaying full cover images and model names. You can now select replacements purely visually.
|
||||
- **Global Health Dashboard (全图体检报表)**: A top-level dashboard in the Doctor panel provides a bird's-eye view of your entire workflow. It categorizes nodes into "Healthy", "Missing", "Replaced", and "Unrelated", giving you instant situational awareness.
|
||||
- **Inline Node Notes (内联节点备注)**: Added the ability to view and edit model notes directly beneath the preview image in the Node Assistant, ensuring zero layout shifting and maintaining a continuous UX flow.
|
||||
- **Local Metadata Parsing Toggle (非C站模型本地解析)**: Added a new explicit toggle in the Settings Hub. This allows users to enable deep local/offline metadata scanning (e.g. for HuggingFace models) without strictly enforcing a Civitai lookup.
|
||||
|
||||
### 🐛 Bug Fixes & UX Polish
|
||||
- **Regex Replacement Bug**: Fixed a critical bug where regex backslashes were swallowed during update patching, causing a syntax error in `main.js` that made the plugin icon disappear.
|
||||
- **Dynamic Regex Splitting**: Ensured file paths are split properly using both forward slashes and backslashes `/[\/]/` for complete cross-platform Windows/Linux compatibility.
|
||||
- **Temporary Feature Suspension**: Temporarily commented out the WIP "Preflight Import Workflow" button while its parsing engine is being finalized.
|
||||
|
||||
## v1.2.0 (The O(1) Speed & Tiered Resolution Update)
|
||||
### 🚀 Architectural Breakthroughs
|
||||
@@ -51,76 +297,76 @@
|
||||
- **Gentle Workflow Error Reminder**: Removed the overly aggressive background ghost-clicker that attempted to clear Vue side-panel errors by simulating clicks. Replaced it with a clean, bilingual user alert gently reminding them to click the [Refresh] button if the ComfyUI Workflow Overview panel still shows red errors.
|
||||
- **Documentation Badges**: Added quick-access badges to the README header for 1-click navigation to the Changelog and Developer Notes on GitHub.
|
||||
|
||||
# 馃搱 Anomalous Model Browser Changelog
|
||||
|
||||
## v1.1.1 (Ultimate Model Resolution & Diagnostic Overhaul)
|
||||
### 馃殌 Enhancements
|
||||
- **Lightweight Scan Speed Demon**: Rewrote the "Lightweight Scan" incremental skip condition. By recognizing that lightweight mode purposefully omits media (`--skip-media`), the scanner now skips models *instantly* if a `.info` file exists, bypassing the heavy SHA256 computation block. Lightweight scans are now truly lightweight, parsing hundreds of gigabytes in mere seconds instead of minutes.
|
||||
- **One-Click Visual Residue Eradication**: Both the post-scan auto-fix and the "One-Click Fix Workflow" settings button have been supercharged. They now aggressively target `node.color`, `node.bgcolor`, and `node.has_errors`. Even if the string matches perfectly but the frontend dropdown cache is just stale, the script violently resets ComfyUI's native red error highlighting. The workflow immediately visually clears without requiring a browser refresh.
|
||||
|
||||
### 馃悰 Bug Fixes
|
||||
- **The "Overlapping Directory" Deadlock**: Fixed a critical bug in `api.py` where fallback `target_size` matching completely aborted if `len(size_matches) > 1`. If users had `extra_model_paths.yaml` aliasing `checkpoints` and `diffusion_models` to the exact same physical folder, `os.walk` naturally duplicated the entries. Implemented an `os.path.realpath` deduplication matrix to fuse these ghosts into a single mathematical truth, restoring size-based missing node resolution to 100% reliability.
|
||||
- **Silent Scanner Crashing**: Fixed a tiny but fatal Python `IndentationError` in `scraper.py` (introduced during the skip-media logic update) that caused the lightweight scanner to silently crash before doing anything. The UI DEVNULL suppression hid the error, creating the illusion of the scan "ignoring" models. The indentation is now strictly PEP-8 compliant.
|
||||
## v1.1.0 (Integrated Gallery & Advanced UX Update)
|
||||
### 鉁?Major Features
|
||||
- **Integrated Image Gallery**: Added a brand new "Gallery" hub that seamlessly syncs with your ComfyUI `output` folder. Browse your generation history natively within the plugin!
|
||||
- **Infinite Lazy Loading**: The gallery utilizes `IntersectionObserver` to automatically load images as you scroll, ensuring zero lag even with thousands of generations.
|
||||
- **Drag-and-Drop Workflow Import**: Every gallery image acts exactly like native ComfyUI assets. You can directly drag any image onto the canvas or a `Load Image` node to effortlessly extract its metadata and workflow.
|
||||
- **Foolproof Image Deletion**: Built an immersive, full-card confirmation overlay for deleting images, replacing the tiny inline buttons. It drastically increases click accuracy and perfectly follows Fitts's Law.
|
||||
|
||||
### 馃拕 UX & UI Polish
|
||||
- **"Intent Delay" Hover Mechanics**: Fixed "Strobe Effects" where rapid mouse sweeping caused flickering UI. Added a customized `0.35s cubic-bezier` curve with a `0.08s` delay to all grid cards and notebook items, ensuring items only pop up when you actually mean to look at them.
|
||||
- **Smart Sidebar Auto-Hide**: When clicking the "Gallery" top menu button, the left folder tree now intelligently auto-hides itself, leaving maximum screen space for viewing your images. It automatically restores when returning to the Models page.
|
||||
- **CSS Grid Refinements**: The gallery uses responsive flex layout (`minmax(250px, 1fr)`), automatically balancing 4 beautiful thumbnails per row in expanded mode.
|
||||
|
||||
|
||||
## v1.0.3 (Responsive UI & Critical Hotfixes)
|
||||
### 馃殌 Enhancements
|
||||
- **Settings Panel Redesign**: Transformed the raw API Key input field into a clean, dedicated `馃攽 API Key Config` modal button. Added detailed, beginner-friendly descriptions for all settings buttons (Scan, Clean, AutoPlay) to clarify their destructive/background behaviors.
|
||||
- **Top Bar Layout Logic**: The header buttons (Models, Settings, etc.) now intelligently hide themselves when the left sidebar folder tree is expanded on narrow screens, automatically reappearing when the tree is collapsed to avoid layout breakage.
|
||||
- **English Typography Polish**: Added specific CSS `.anomalous-lang-en` scaling to increase the English header buttons to `1.05em` with generous padding, perfectly matching the visual weight of the Chinese UI.
|
||||
- **Pure Icon Narrow Sidebar**: The Notebook's internal left sidebar is now extremely responsive. On narrow screens (`< 600px`), it aggressively collapses to `60px` width, hiding all notebook names and displaying only pure document icons for a sleek micro-toolbar feel.
|
||||
- **Logical Settings Order**: Swapped the positions of the language toggle and the Close button in the settings panel. The language toggle now sits proudly at the top with a distinct margin, while the red Close button acts as a final anchor at the bottom.
|
||||
|
||||
### 馃悰 Bug Fixes
|
||||
- **The "Missing Plugin Button" Trap**: Completely eliminated a notorious "old bug" where the floating plugin button would vanish forever if a user resized their window or switched to a smaller monitor (due to `localStorage` caching out-of-bounds `left/top` coordinates like `3000px`). The button now forces a safe bottom-right fallback if the saved coordinates exceed `window.innerWidth`.
|
||||
- **Fatal Vite Preload Crash**: Fixed a critical `SyntaxError` caused by a missing template literal backtick (`` ` ``) in the translation dictionary. This tiny missing character previously triggered a `[vite:preloadError]`, causing the entire `main.js` to abort loading before rendering the UI.
|
||||
- **Double Icon Squashing**: Addressed a bug where the top header's "Notebooks" button would turn into a weird, empty dark gray rectangle on narrow screens. Extracted the `馃搼` emoji out of the translation string wrapper so the icon remains fully visible even when the text is responsively hidden.
|
||||
|
||||
---
|
||||
|
||||
# v1.0.2 (UX & Polish Hotfix)
|
||||
## 馃殌 Enhancements
|
||||
- **Magnetic Matrix Deployment**: When sending a Notebook to the canvas, the entire architecture (Checkpoint + Loras + CLIP Encoders) is now mathematically arranged in a clean, linear assembly line instead of clumping together. The entire node group magnetically sticks to your cursor until you click the canvas to drop it.
|
||||
- **Smart Session Memory**: Re-opening the Notebook panel now instantly resumes your exact previous editing session rather than resetting to a blank state.
|
||||
- **Auto-Focus First Notebook**: If no session is active, opening the Notebook modal will automatically open your first existing notebook to prevent "empty screen" fatigue.
|
||||
- **Instant Save Feedback**: Added a satisfying 1.5-second green `鉁卄 transient animation to the notebook save button for psychological assurance.
|
||||
- **Clearer Documentation**: Updated README codebase size estimation to reflect reality (~150KB - 200KB) due to the massive features added, while still maintaining pure Vanilla JS zero-dependency dominance.
|
||||
|
||||
## 馃悰 Bug Fixes
|
||||
- **Double Icon Glitch**: Removed a hardcoded `鉃昤 emoji on the "Apply to Canvas" button that duplicated the icon injected by the translation engine.
|
||||
- **Localization Override**: Fixed a critical bug where scan success dialogs were defaulting to English despite the UI being set to Chinese. This was caused by the plugin improperly reading the host ComfyUI root DOM `lang` attribute instead of the plugin's internal state.
|
||||
- **Safer Reboot Advice**: Replaced misleading mentions of "Refresh ComfyUI" with strict advice to "Restart ComfyUI backend" after model scans to prevent deep path caching crashes.
|
||||
|
||||
---
|
||||
|
||||
# v1.0.1 (Notebook System Expansion)
|
||||
## 馃専 New Features
|
||||
- **Notebook System (`馃搼 Notebooks`)**: A powerful new drafting space designed for workflow preparation.
|
||||
- **Bilingual Prompt Editor**: A dual-pane translation workspace (English/Chinese) that supports Google and DeepL translation via local Python backend (caching in `translations.json`).
|
||||
- **Interactive Tag Engine**: Automatically splits prompts by commas into styled tags. Includes hover-sync between English and Chinese tags, individual tag editing, and 1-click clipboard copying.
|
||||
- **Dynamic Architecture Filtering**: The Notebook allows selecting a Base Model directly parsed from your local library (e.g., `SDXL`, `SD 1.5`, `Pony`), strictly omitting unowned default models. Selecting an architecture perfectly filters the Main Model and Lora galleries below it.
|
||||
- **One-Click Deployment**: The **"馃殌 Send to Canvas"** button seamlessly injects your configured Checkpoint loader, Lora loaders, and CLIP Text Encode nodes directly onto the ComfyUI canvas, fully wired and ready to go.
|
||||
|
||||
## 馃洜锔?Enhancements & UX Tweaks
|
||||
- **Zero-Wait Delete Actions**: The "Delete Notebook" interaction has been overhauled. Instead of a mandatory 3-second wait, it now transforms into an explicit `[鈿狅笍 Sure?]` and `[鉁昡` cancellation button combo, dramatically improving the user experience for accidental clicks.
|
||||
- **Buttery Smooth Hover Dynamics**: Replaced harsh instant hovers on model cards with a `cubic-bezier(0.2, 0.8, 0.2, 1)` transition and a micro `0.02s` delay. This eliminates strobe-flashing when the mouse sweeps quickly across galleries, providing an extremely premium damping feel.
|
||||
- **Smart Startup Navigation**: The plugin no longer opens to an empty root directory by default. It now intelligently scans all loaded trees on startup and auto-focuses the first valid folder containing models, ensuring you dive straight into your library.
|
||||
- **Deep i18n Integration**: Total internationalization coverage extended to all deep-layer UI elements, including Notebook buttons, translation triggers, and dynamic dialogs. Language toggling triggers an instant virtual DOM re-render without page refreshes.
|
||||
|
||||
## 馃悰 Bug Fixes
|
||||
- **Base Model Metadata Pollution**: Fixed an issue where the `baseModel` dropdown forcefully injected unowned models (like `HunyuanVideo` or `OmniGen`) into the UI. The filter now strictly traverses actual local `.safetensors` headers and `.info` configurations to provide a 100% accurate reflection of your physical library.
|
||||
- **Nested Scrolling Traps**: Eliminated a severe UX "scroll trap" within the Notebook dual-pane editor by removing inner `max-height` constraints, allowing natural flex expansion utilizing the modal's primary scrollbar.
|
||||
- **Model Selection Jumping**: Fixed layout shifting caused by variable-length text tags by implementing a rigid CSS grid/flex architecture (`.anomalous-nb-tag-row`).
|
||||
# 馃搱 Anomalous Model Browser Changelog
|
||||
|
||||
## v1.1.1 (Ultimate Model Resolution & Diagnostic Overhaul)
|
||||
### 馃殌 Enhancements
|
||||
- **Lightweight Scan Speed Demon**: Rewrote the "Lightweight Scan" incremental skip condition. By recognizing that lightweight mode purposefully omits media (`--skip-media`), the scanner now skips models *instantly* if a `.info` file exists, bypassing the heavy SHA256 computation block. Lightweight scans are now truly lightweight, parsing hundreds of gigabytes in mere seconds instead of minutes.
|
||||
- **One-Click Visual Residue Eradication**: Both the post-scan auto-fix and the "One-Click Fix Workflow" settings button have been supercharged. They now aggressively target `node.color`, `node.bgcolor`, and `node.has_errors`. Even if the string matches perfectly but the frontend dropdown cache is just stale, the script violently resets ComfyUI's native red error highlighting. The workflow immediately visually clears without requiring a browser refresh.
|
||||
|
||||
### 馃悰 Bug Fixes
|
||||
- **The "Overlapping Directory" Deadlock**: Fixed a critical bug in `api.py` where fallback `target_size` matching completely aborted if `len(size_matches) > 1`. If users had `extra_model_paths.yaml` aliasing `checkpoints` and `diffusion_models` to the exact same physical folder, `os.walk` naturally duplicated the entries. Implemented an `os.path.realpath` deduplication matrix to fuse these ghosts into a single mathematical truth, restoring size-based missing node resolution to 100% reliability.
|
||||
- **Silent Scanner Crashing**: Fixed a tiny but fatal Python `IndentationError` in `scraper.py` (introduced during the skip-media logic update) that caused the lightweight scanner to silently crash before doing anything. The UI DEVNULL suppression hid the error, creating the illusion of the scan "ignoring" models. The indentation is now strictly PEP-8 compliant.
|
||||
## v1.1.0 (Integrated Gallery & Advanced UX Update)
|
||||
### 鉁?Major Features
|
||||
- **Integrated Image Gallery**: Added a brand new "Gallery" hub that seamlessly syncs with your ComfyUI `output` folder. Browse your generation history natively within the plugin!
|
||||
- **Infinite Lazy Loading**: The gallery utilizes `IntersectionObserver` to automatically load images as you scroll, ensuring zero lag even with thousands of generations.
|
||||
- **Drag-and-Drop Workflow Import**: Every gallery image acts exactly like native ComfyUI assets. You can directly drag any image onto the canvas or a `Load Image` node to effortlessly extract its metadata and workflow.
|
||||
- **Foolproof Image Deletion**: Built an immersive, full-card confirmation overlay for deleting images, replacing the tiny inline buttons. It drastically increases click accuracy and perfectly follows Fitts's Law.
|
||||
|
||||
### 馃拕 UX & UI Polish
|
||||
- **"Intent Delay" Hover Mechanics**: Fixed "Strobe Effects" where rapid mouse sweeping caused flickering UI. Added a customized `0.35s cubic-bezier` curve with a `0.08s` delay to all grid cards and notebook items, ensuring items only pop up when you actually mean to look at them.
|
||||
- **Smart Sidebar Auto-Hide**: When clicking the "Gallery" top menu button, the left folder tree now intelligently auto-hides itself, leaving maximum screen space for viewing your images. It automatically restores when returning to the Models page.
|
||||
- **CSS Grid Refinements**: The gallery uses responsive flex layout (`minmax(250px, 1fr)`), automatically balancing 4 beautiful thumbnails per row in expanded mode.
|
||||
|
||||
|
||||
## v1.0.3 (Responsive UI & Critical Hotfixes)
|
||||
### 馃殌 Enhancements
|
||||
- **Settings Panel Redesign**: Transformed the raw API Key input field into a clean, dedicated `馃攽 API Key Config` modal button. Added detailed, beginner-friendly descriptions for all settings buttons (Scan, Clean, AutoPlay) to clarify their destructive/background behaviors.
|
||||
- **Top Bar Layout Logic**: The header buttons (Models, Settings, etc.) now intelligently hide themselves when the left sidebar folder tree is expanded on narrow screens, automatically reappearing when the tree is collapsed to avoid layout breakage.
|
||||
- **English Typography Polish**: Added specific CSS `.anomalous-lang-en` scaling to increase the English header buttons to `1.05em` with generous padding, perfectly matching the visual weight of the Chinese UI.
|
||||
- **Pure Icon Narrow Sidebar**: The Notebook's internal left sidebar is now extremely responsive. On narrow screens (`< 600px`), it aggressively collapses to `60px` width, hiding all notebook names and displaying only pure document icons for a sleek micro-toolbar feel.
|
||||
- **Logical Settings Order**: Swapped the positions of the language toggle and the Close button in the settings panel. The language toggle now sits proudly at the top with a distinct margin, while the red Close button acts as a final anchor at the bottom.
|
||||
|
||||
### 馃悰 Bug Fixes
|
||||
- **The "Missing Plugin Button" Trap**: Completely eliminated a notorious "old bug" where the floating plugin button would vanish forever if a user resized their window or switched to a smaller monitor (due to `localStorage` caching out-of-bounds `left/top` coordinates like `3000px`). The button now forces a safe bottom-right fallback if the saved coordinates exceed `window.innerWidth`.
|
||||
- **Fatal Vite Preload Crash**: Fixed a critical `SyntaxError` caused by a missing template literal backtick (`` ` ``) in the translation dictionary. This tiny missing character previously triggered a `[vite:preloadError]`, causing the entire `main.js` to abort loading before rendering the UI.
|
||||
- **Double Icon Squashing**: Addressed a bug where the top header's "Notebooks" button would turn into a weird, empty dark gray rectangle on narrow screens. Extracted the `馃搼` emoji out of the translation string wrapper so the icon remains fully visible even when the text is responsively hidden.
|
||||
|
||||
---
|
||||
|
||||
# v1.0.2 (UX & Polish Hotfix)
|
||||
## 馃殌 Enhancements
|
||||
- **Magnetic Matrix Deployment**: When sending a Notebook to the canvas, the entire architecture (Checkpoint + Loras + CLIP Encoders) is now mathematically arranged in a clean, linear assembly line instead of clumping together. The entire node group magnetically sticks to your cursor until you click the canvas to drop it.
|
||||
- **Smart Session Memory**: Re-opening the Notebook panel now instantly resumes your exact previous editing session rather than resetting to a blank state.
|
||||
- **Auto-Focus First Notebook**: If no session is active, opening the Notebook modal will automatically open your first existing notebook to prevent "empty screen" fatigue.
|
||||
- **Instant Save Feedback**: Added a satisfying 1.5-second green `鉁卄 transient animation to the notebook save button for psychological assurance.
|
||||
- **Clearer Documentation**: Updated README codebase size estimation to reflect reality (~150KB - 200KB) due to the massive features added, while still maintaining pure Vanilla JS zero-dependency dominance.
|
||||
|
||||
## 馃悰 Bug Fixes
|
||||
- **Double Icon Glitch**: Removed a hardcoded `鉃昤 emoji on the "Apply to Canvas" button that duplicated the icon injected by the translation engine.
|
||||
- **Localization Override**: Fixed a critical bug where scan success dialogs were defaulting to English despite the UI being set to Chinese. This was caused by the plugin improperly reading the host ComfyUI root DOM `lang` attribute instead of the plugin's internal state.
|
||||
- **Safer Reboot Advice**: Replaced misleading mentions of "Refresh ComfyUI" with strict advice to "Restart ComfyUI backend" after model scans to prevent deep path caching crashes.
|
||||
|
||||
---
|
||||
|
||||
# v1.0.1 (Notebook System Expansion)
|
||||
## 馃専 New Features
|
||||
- **Notebook System (`馃搼 Notebooks`)**: A powerful new drafting space designed for workflow preparation.
|
||||
- **Bilingual Prompt Editor**: A dual-pane translation workspace (English/Chinese) that supports Google and DeepL translation via local Python backend (caching in `translations.json`).
|
||||
- **Interactive Tag Engine**: Automatically splits prompts by commas into styled tags. Includes hover-sync between English and Chinese tags, individual tag editing, and 1-click clipboard copying.
|
||||
- **Dynamic Architecture Filtering**: The Notebook allows selecting a Base Model directly parsed from your local library (e.g., `SDXL`, `SD 1.5`, `Pony`), strictly omitting unowned default models. Selecting an architecture perfectly filters the Main Model and Lora galleries below it.
|
||||
- **One-Click Deployment**: The **"馃殌 Send to Canvas"** button seamlessly injects your configured Checkpoint loader, Lora loaders, and CLIP Text Encode nodes directly onto the ComfyUI canvas, fully wired and ready to go.
|
||||
|
||||
## 馃洜锔?Enhancements & UX Tweaks
|
||||
- **Zero-Wait Delete Actions**: The "Delete Notebook" interaction has been overhauled. Instead of a mandatory 3-second wait, it now transforms into an explicit `[鈿狅笍 Sure?]` and `[鉁昡` cancellation button combo, dramatically improving the user experience for accidental clicks.
|
||||
- **Buttery Smooth Hover Dynamics**: Replaced harsh instant hovers on model cards with a `cubic-bezier(0.2, 0.8, 0.2, 1)` transition and a micro `0.02s` delay. This eliminates strobe-flashing when the mouse sweeps quickly across galleries, providing an extremely premium damping feel.
|
||||
- **Smart Startup Navigation**: The plugin no longer opens to an empty root directory by default. It now intelligently scans all loaded trees on startup and auto-focuses the first valid folder containing models, ensuring you dive straight into your library.
|
||||
- **Deep i18n Integration**: Total internationalization coverage extended to all deep-layer UI elements, including Notebook buttons, translation triggers, and dynamic dialogs. Language toggling triggers an instant virtual DOM re-render without page refreshes.
|
||||
|
||||
## 馃悰 Bug Fixes
|
||||
- **Base Model Metadata Pollution**: Fixed an issue where the `baseModel` dropdown forcefully injected unowned models (like `HunyuanVideo` or `OmniGen`) into the UI. The filter now strictly traverses actual local `.safetensors` headers and `.info` configurations to provide a 100% accurate reflection of your physical library.
|
||||
- **Nested Scrolling Traps**: Eliminated a severe UX "scroll trap" within the Notebook dual-pane editor by removing inner `max-height` constraints, allowing natural flex expansion utilizing the modal's primary scrollbar.
|
||||
- **Model Selection Jumping**: Fixed layout shifting caused by variable-length text tags by implementing a rigid CSS grid/flex architecture (`.anomalous-nb-tag-row`).
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Gemini 工作入口
|
||||
|
||||
在本插件中修改任何文件前,先阅读并遵守根目录的 [AGENTS.md](AGENTS.md)。它是本仓库开发与维护规范的唯一正文,适用于 UX 调整、修复和重构。
|
||||
|
||||
随后阅读 [ARCHITECTURE.md](ARCHITECTURE.md),按其中的阅读地图加载与当前任务有关的专题。
|
||||
|
||||
不要只凭当前任务描述直接追加实现。先确认现有责任模块、状态来源、关闭清理和样式规则;完成后按规范验证并报告结果。
|
||||
|
||||
如果当前工具没有加载本文件,任务发起者应明确要求 Gemini 阅读 `GEMINI.md` 和 `AGENTS.md`。文件的存在不代表工具已实际读取,更不代表检查已自动执行。
|
||||
|
||||
前端设计任务另读 [前端设计与协作方案](docs/plans/frontend-design-brief.md);具体功能计划按该文链接进入,不使用历史视觉建议作为当前任务单。
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 DemonGatanjieu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,137 +1,211 @@
|
||||
# Anomalous Model Browser
|
||||
<div align="center">
|
||||
|
||||
[](CHANGELOG.md) [](https://github.com/ltdrdata/ComfyUI-Manager)
|
||||
# 🚀 Anomalous Model Browser
|
||||
|
||||
[English](#english) | [中文](#中文)
|
||||
**A Comprehensive Creative Workspace & Model Manager for ComfyUI**
|
||||
*零依赖 C 站元数据抓取 · 智能模型医生 · 可视化节点助手 · 工作流配方与参数笔记*
|
||||
|
||||
<br/>
|
||||
|
||||
[](https://github.com/ltdrdata/ComfyUI-Manager)
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
[](https://youtu.be/hAvsj7uiaCw)
|
||||
|
||||
<br/>
|
||||
|
||||
[**English**](#-english) | [**中文说明**](#-中文)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<h2 id="english">English</h2>
|
||||
<h2 id="english">🇬🇧 English</h2>
|
||||
|
||||
A lightweight (< 250KB), zero-dependency ComfyUI model browser plugin featuring hash-level workflow repair, a powerful smart notebook, deep local scanning, and gallery management.
|
||||
> **Anomalous Model Browser** is a comprehensive creative workspace and model management suite for ComfyUI. It integrates hash-based metadata indexing, workflow healing (Model Doctor), canvas visual model swapping (Node Assistant), a unified Material Library with polymorphic canvas drag-and-drop, and immutable Workflow Recipes.
|
||||
|
||||
### Core Feature 1: Hash-Level Workflow Repair (Gene-Level Self-Healing)
|
||||
### 🎬 Video Walkthrough & Demos
|
||||
* 📺 **YouTube**: [Watch Quick Walkthrough on YouTube](https://youtu.be/hAvsj7uiaCw)
|
||||
* 📺 **Bilibili**: [Watch Video Demo on Bilibili (在哔哩哔哩观看)](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
|
||||
**Workflows and images generated or saved while this plugin is active will have their models' Civitai Hashes automatically embedded.**
|
||||
### 🌟 Core Capabilities
|
||||
|
||||
> ⚠️ **Crucial Prerequisite**: After installing the plugin, you MUST run a **[Scan]** from the UI to build your local hash database. Once scanned, any workflows or images you generate thereafter will automatically carry the hash metadata required for self-healing.
|
||||
| 🚀 Primary View | 💡 Description |
|
||||
| :--- | :--- |
|
||||
| **Model Browser (📦 Models)** | Browse models categorized by folder (checkpoints, unet, loras). Displays Civitai metadata, triggers, architectures, and high-res covers. Supports visual swapping, renaming, and notes. |
|
||||
| **History Gallery (🖼️ Gallery)** | Native viewer for the ComfyUI `output` directory. Features multi-dimensional search (prompt, model, seed, hash) with removable filter blocks, mouse-wheel zoom, and drag-to-canvas workflow reconstruction. |
|
||||
| **Workflow Recipes (🪡 Workflows)** | Save full workflows or partial subgraphs. Displays live model readiness status (e.g. all models ready). Open recipes in a new canvas or drag cards directly onto the canvas to load. |
|
||||
| **Toolbox & Bottom Dock (🧰)** | Lower-left navigation bar featuring the Toolbox as the first icon. Directly hosts 4 frequent tools (Scan, Doctor, Assistant, Material Library) plus Settings. Clicking the Toolbox opens a panel containing 5 secondary utilities (Workflow Transfer, Prompt Studio, Prompt Translator, Model Source Hub, Prompt Notes). |
|
||||
| **Workflow Repair (Model Doctor 🩺)** | Automatically detects broken nodes in external workflows and reconnects them to local models by comparing SHA-256 hashes and file sizes. |
|
||||
| **Node Assistant & Presets (🤖)** | Canvas-linked sidebar. Select any node on the canvas to inspect covers/triggers, visually swap models, or apply recipe parameters transactionally. |
|
||||
| **Material Library & Polymorphic Drag (✨)** | Save curated bundles from output PNGs (workflow, provenance, node parameters). Drop onto canvas to open workflow, drop prompts to auto-create native CLIP nodes, or drop onto existing nodes to inject parameters. |
|
||||
|
||||
When loading a workflow or image that was saved with this plugin, if you encounter red "Missing Node" errors due to mismatched model names:
|
||||
1. Click the **Repair Workflow** button.
|
||||
2. The plugin will scan your local directory using the embedded Hash data.
|
||||
3. Once a match is found, it automatically reconnects and replaces the missing nodes.
|
||||
### 📦 Quick Installation
|
||||
|
||||
*(Note: The plugin relies on these injected hashes for precise matching. Historical workflows or images generated without this plugin will lack the necessary hash data and cannot be automatically repaired.)*
|
||||
1. Open your terminal in the ComfyUI `custom_nodes` directory:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
2. Restart ComfyUI. *(Alternatively, install via **ComfyUI Manager** by searching for `Anomalous Model Browser`)*
|
||||
3. **Updating**: click the **!** button in the browser header; the current version is shown at the top, and **Check for updates / switch version** opens the version panel. From there you can update to the newest published release, roll back to an earlier one, or return to the latest version, then restart ComfyUI. The plugin never checks for updates on its own.
|
||||
|
||||
> 💡 **Auto-Detect Missing Models**: By default, the plugin will NOT silently scan your disk or interrupt your workflow loading with popups if you have missing models. You can turn this feature **ON** manually in the Settings Hub if you want active workflow monitoring and auto-healing prompts.
|
||||
Open the browser via **Ctrl + Shift + M**, the floating canvas orb (**📦**), or **Extensions → Anomalous Model Browser** in ComfyUI's menu. **ComfyUI Settings → Anomalous Model Browser → Interface** lets you customize the shortcut, configure the UI language, and select an entry mode (floating button, action-bar button, or Extensions menu only).
|
||||
|
||||
### Core Feature 2: Smart Notebook (Workflow Drafting)
|
||||
<br/>
|
||||
|
||||
Since the local scanner natively identifies the architecture (Base Model) of your files, the Notebook acts as a powerful drafting area:
|
||||
* **Architecture Filtering**: Select a Base Model, and the notebook will strictly filter and display only the compatible Main Models and Loras.
|
||||
* **Visual Selection**: Displays preview images for your models directly within the notebook interface.
|
||||
* **Prompt Management**: Paste your prompts to instantly receive a bilingual translation for easy side-by-side editing.
|
||||
* **One-Click Deploy**: Press "Send to Canvas" and your entire selected stack (Main Model, Loras, Prompts) will drop onto the canvas fully wired up.
|
||||
<details>
|
||||
<summary><b>📖 Click to Expand: Step-by-Step Operating Guide</b></summary>
|
||||
|
||||
### Core Feature 3: Deep Scanning & Metadata Management
|
||||
<br/>
|
||||
|
||||
* **O(1) Instant Hash Extraction**: Instead of computing hashes for gigabytes of data, the plugin reads the header of `.safetensors` files to extract the hash instantly. It then automatically downloads the corresponding `.info` and previews from Civitai.
|
||||
* **Offline Tensor Gene Inference**: For private models or models not on Civitai, the plugin analyzes the internal tensor structure (e.g., detecting `double_blocks` for Flux) to accurately deduce the Base Model architecture, ensuring even orphaned models work seamlessly with the Notebook filtering.
|
||||
* **Model Deduplication**: Physically identifies and cleans up redundant `.safetensors` files that share the exact same hash under different names.
|
||||
* **Model Details**: Read usage instructions and 1-click copy trigger words directly from the model page UI.
|
||||
#### 1. Top Navigation Views (三大顶栏视图)
|
||||
The header provides 3 primary workspaces:
|
||||
* **Models (📦)**: Main model catalog. The left panel shows folder trees (`checkpoints`, `unet`, `loras`), while the center grid displays model cards with previews, trigger tags, and metadata.
|
||||
* **Gallery (🖼️)**: Historical output browser. The top search bar filters outputs by prompt, model name, seed, filename, or model hash; each search keyword forms an independent block with a `×` button for instant removal. Dragging an image onto the canvas reconstructs its embedded workflow.
|
||||
* **Workflows (🪡)**: Workflow Recipe studio. Filter by `[All]`, `[Full Workflows]`, or `[Subgraphs]`. Click `Save Current Workflow` to save the active canvas, or drag any recipe card directly onto the canvas to load it.
|
||||
|
||||
### Other Practical Features
|
||||
#### 2. Bottom Navigation & Toolbox Catalog (🧰 第一个图标:实用工具箱)
|
||||
* **Dock Layout**: The lower-left navigation bar hosts 6 permanent buttons in a fixed layout:
|
||||
1. **Toolbox (🧰)**: First anchor on the left. Opens the Toolbox panel for secondary tools.
|
||||
2. **Scan Wizard (🔄)**: Direct shortcut to launch model library scanning.
|
||||
3. **Model Doctor (🩺)**: Direct shortcut to diagnose and repair missing workflow nodes.
|
||||
4. **Node Assistant (🤖)**: Direct shortcut to inspect canvas node models and inject parameter presets.
|
||||
5. **Material Library (✨)**: Direct shortcut to manage curated bundles and drag assets to the canvas.
|
||||
6. **Settings (⚙️)**: Fixed anchor on the far right for global preferences.
|
||||
* **Toolbox Catalog**: Clicking the first icon (**🧰 实用工具箱**) opens a dedicated popup panel containing 5 secondary utilities:
|
||||
1. **Workflow Transfer Center (⇄ 导入导出)**: Lossless AMB format workflow share code import and export. Allows importing workflows from text codes or exporting the current canvas with embedded hashes.
|
||||
2. **Prompt Studio (🎛️ 提示词工坊)**: Modular prompt mixer deck. Extract prompt cards from history or materials, reorder segments, and assemble positive/negative prompts.
|
||||
3. **Prompt Translator (🌐 翻译助手)**: Built-in bilingual prompt translation between Chinese and English without leaving the interface.
|
||||
4. **Model Source Hub (🔗 模型来源)**: Dual-scope (workflow and library) download URL inspector. Navigates to Civitai, HuggingFace, Liblib, or ModelScope, and generates non-intrusive canvas Note nodes.
|
||||
5. **Prompt Notes (📑 提示词笔记)**: Lightweight notebook for drafting, editing, and managing reusable prompt snippets saved locally in the user directory (`workflows/anomalous_notebooks`).
|
||||
|
||||
* **Sidebar Docking & Node Dropping**: Dock the browser to the sidebar. Click the `+` icon in the top right of any model card to instantly drop its loader node onto your canvas.
|
||||
* **O(1) Fast Scanning**: No Node.js required. Exceptional scroll performance with Eco Mode (hover to play) and Auto-play toggles.
|
||||
* **Native Output Gallery**: Browse ComfyUI output history with infinite lazy loading, and drag-and-drop images directly to the canvas to restore workflows.
|
||||
* **Model Management**: Securely delete models from the UI, automatically cleaning up associated metadata and preview images.
|
||||
#### 3. Scan Wizard (🔄 扫描向导)
|
||||
* **Location**: Direct shortcut on the bottom dock (2nd icon).
|
||||
* **Operation**: Scan configured model directories to compute file hashes, download Civitai covers, tags, and architectures, and build the local offline database.
|
||||
|
||||
### Installation Guide
|
||||
#### 4. Model Doctor (🩺 模型医生)
|
||||
* **Location**: Direct shortcut on the bottom dock (3rd icon).
|
||||
* **Operation**: When loading an external workflow with missing red nodes, Model Doctor inspects embedded provenance hashes and file sizes to reconnect local matches automatically. The "View Hash" button opens a side-by-side SHA256 comparison modal.
|
||||
|
||||
Open your terminal, navigate to the ComfyUI `custom_nodes` folder, and run the following commands:
|
||||
#### 5. Node Assistant & Parameter Presets (🤖 节点助手)
|
||||
* **Location**: Direct shortcut on the bottom dock (4th icon).
|
||||
* **Actions**: Select a model node on the canvas to view high-res previews and trigger words, or swap models visually. Switch to **Parameter Presets** to inject recipe-saved node values (e.g., KSampler steps, CFG, denoise) into matching canvas nodes with safe seed preservation.
|
||||
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/your-username/Anomalous_Model_Browser.git
|
||||
```
|
||||
*(Note: Restart ComfyUI after cloning to load the plugin. Alternatively, search for `Anomalous Model Browser` in the ComfyUI Manager and click Install!)*
|
||||
#### 6. Material Library & Polymorphic Canvas Drag (✨ 素材库与多态拖拽)
|
||||
* **Location**: Direct shortcut on the bottom dock (5th icon).
|
||||
* **Capture**: In Gallery or Workflows, open an image's parameter inspector to save its image, workflow, and node blocks as a curated material bundle (`workflows/anomalous_materials`).
|
||||
* **Polymorphic Canvas Drag**:
|
||||
- Dragging a workflow material to an empty canvas area opens the full workflow.
|
||||
- Dragging a prompt-only material to an empty canvas auto-creates native `CLIPTextEncode` nodes with distinct negative (`#532323`) and positive (`#235327`) styling.
|
||||
- Dragging materials onto existing canvas nodes injects matching parameter values directly.
|
||||
|
||||
### Quick Start
|
||||
#### 7. Global Settings (⚙️ 设置)
|
||||
* **Location**: Direct shortcut on the far right of the lower-left navigation bar (6th icon).
|
||||
* **Options**: Interface language, font scale, thumbnail rendering, video cover hover behavior, and model folder blacklist.
|
||||
|
||||
**Initial Setup (Required):**
|
||||
1. **Step 1**: Click the **Model Browser** button to open the panel.
|
||||
2. **Step 2**: Navigate to the Settings tab and run a **Full Scan** (or scan individual folders) to build your local hash database.
|
||||
> [!WARNING]
|
||||
> **Beta Data Protection:** Workflow Recipes, Material Library, and Parameter Presets are currently in active preview. Please back up `workflows/anomalous_recipes`, `workflows/anomalous_materials`, and `workflows/anomalous_parameters` inside your ComfyUI user directory before updating.
|
||||
|
||||
**Standard Usage (Adding Nodes):**
|
||||
- Browse your local models. Click the **`+`** icon on any model card to instantly drop its loader node onto the canvas.
|
||||
|
||||
**Repairing Broken Workflows:**
|
||||
1. **Step 1**: Drag and drop a reference image containing Hash info (generated with this plugin) into the ComfyUI canvas.
|
||||
2. **Step 2**: If nodes turn red, click the prompted **Repair Workflow** button and wait for the plugin to restore node connections.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<h2 id="中文">中文</h2>
|
||||
<h2 id="中文">🇨🇳 中文说明</h2>
|
||||
|
||||
一个不到 250KB、零依赖、集成了哈希级工作流修复、智能笔记本、深度本地扫描与图库管理的 ComfyUI 模型浏览器插件。
|
||||
> **Anomalous Model Browser** 是为 ComfyUI 设计的综合创作工作台与模型管理套件。系统集成了基于文件哈希的元数据提取、工作流缺失模型自愈(模型医生)、画布节点可视化换模与 LoRA 插入(节点助手)、支持多态画布拖拽的统一素材库,以及工作流配方体系。
|
||||
|
||||
### 核心特性一:哈希级工作流修复(基因级自愈)
|
||||
### 🎬 视频演示与教程
|
||||
* 📺 **哔哩哔哩 (Bilibili)**:[在 B 站观看快速上手与使用演示](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
* 📺 **YouTube**:[在 YouTube 观看视频演示](https://youtu.be/hAvsj7uiaCw)
|
||||
|
||||
**在启用本插件的状态下,生成或保存的工作流与图片,都会被自动注入模型对应的 Civitai Hash 和文件信息。**
|
||||
### 🌟 核心特性速览
|
||||
|
||||
> ⚠️ **重要前置条件**:安装插件后,请务必先在界面内运行一次**【扫描】**,建立本地哈希数据库。此后,您导出的所有工作流与图片才会自带模型特征,享有自愈能力。
|
||||
| 🚀 核心视图与工具 | 💡 详细说明 |
|
||||
| :--- | :--- |
|
||||
| **模型浏览 (📦 模型)** | 本地模型管理网格。左侧树状目录分类(checkpoints、unet、loras),卡片展示 C 站元数据、触发词、基础架构与高清封面,支持看图换模与自定义备注。 |
|
||||
| **出图图库 (🖼️ 图库)** | 原生读取本地 `output` 文件夹,支持滚轮缩放与安全删除;顶部搜索栏支持按提示词、模型名、LoRA、seed、文件名或模型哈希多维检索,生成可点击独立移除的搜索标签块;拖拽图片原地还原工作流。 |
|
||||
| **工作流配方 (🪡 工作流)** | 工作流配方工坊。支持保存完整工作流或局部子图,实时显示模型就绪状态(如“全部模型就绪”);支持一键在新画布打开或直接拖拽卡片到画布空白处载入。 |
|
||||
| **实用工具箱与底栏 (🧰)** | 侧边栏左下角第一个图标为工具箱,底栏直达 4 大常用工具(扫描向导、模型医生、节点助手、素材库)与全局设置;点击工具箱弹出浮窗面板,提供 5 大扩展工具(导入导出、提示词工坊、翻译助手、模型来源、提示词笔记)。 |
|
||||
| **节点智能修复 (模型医生 🩺)** | 导入他人工作流发生节点爆红时,自动比对本地模型 SHA256 哈希与字节大小,一键替换为有效路径,支持哈希透视比对。 |
|
||||
| **节点助手与参数预设 (🤖)** | 画布选中节点即可可视化选图换模型、向兼容链路插入 LoRA,或一键应用工作流配方中沉淀的节点参数(自动跳过易变种子)。 |
|
||||
| **统一素材库与多态拖拽 (✨)** | 原生 PNG 资产一键打包保存(完整工作流、模型血缘与节点参数);多态拖拽:拖至空白画布还原工作流或生成原生红绿提示词节点,拖至已有节点智能注入参数。 |
|
||||
|
||||
当你拖入一张由此插件保存的图片或工作流时,如果因为环境不同导致模型找不到、节点全红报错:
|
||||
1. 点一下界面上的**修复工作流**按钮。
|
||||
2. 插件会自动根据内置的 Hash 数据,在你的本地目录中精准寻找对应的模型。
|
||||
3. 找到后自动重新连线替换,哪怕你把模型改成了任意名字。
|
||||
### 📦 快速安装
|
||||
|
||||
*(注意:系统依赖模型哈希进行精准匹配。如果在没有安装本插件前生成的旧工作流,或他人未使用本插件生成的图片,由于缺失哈希信息,将无法触发全自动修复,仍需手动重选。)*
|
||||
1. 在 ComfyUI 的 `custom_nodes` 目录下打开终端执行:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
2. 重启 ComfyUI 即可使用。(*也可以直接在 **ComfyUI Manager** 搜索 `Anomalous Model Browser` 点击安装*)
|
||||
3. **更新插件**:点击顶栏的 **!** 按钮,顶部会显示当前版本,点 **检查更新 / 切换版本** 进入版本面板。可以一键更新到最新发布版本、回退到以前的版本,或回到最新版,之后重启 ComfyUI 即可。插件不会自己联网检查更新。
|
||||
|
||||
> 💡 **自动检测缺失模型**:为了避免打断用户加载工作流的连贯性,插件**默认不会**在后台静默扫描本地磁盘,也**不会**在发现缺失模型时自动弹窗。如果您希望开启工作流缺失模型的主动监控与弹窗提示,请前往设置面板中手动将其**开启**。
|
||||
可以按 **Ctrl + Shift + M**、点击画布上的悬浮 **📦**,或从 ComfyUI 顶部菜单 **扩展 → Anomalous Model Browser** 打开。**ComfyUI 设置 → Anomalous Model Browser → 界面** 会显示当前快捷键,并可直接打开 ComfyUI 原生录入窗口修改,因此冲突与保留按键检查仍然只有一套。该页面还可以让插件语言跟随 ComfyUI 或单独固定为中文/English,并严格三选一显示悬浮入口、运行按钮旁的原生顶部入口或仅使用扩展菜单。只有选择悬浮入口时才显示大小和样式选项。顶部栏重绘不会擅自改变所选模式,扩展菜单命令则始终保留,便于恢复设置。
|
||||
|
||||
### 核心特性二:智能笔记本(工作流草稿本)
|
||||
<br/>
|
||||
|
||||
得益于本地扫描功能可以精准识别并提取模型的基础架构 (Base Model),笔记本模块提供了极为强大的草稿与组装功能:
|
||||
* **架构隔离筛选**:在笔记本中选定 Base Model 后,系统会自动为你筛选出与之完全兼容的主模型和 Lora,杜绝搭配错误。
|
||||
* **可视化挑选**:直接在笔记本页面中显示模型的关联预览图(利用抓取的 C站 数据)。
|
||||
* **提示词翻译对照**:粘贴提示词后,支持一键翻译,形成双语对照,方便打草稿和修改。
|
||||
* **免连线一键发布**:配置好模型和提示词后,点击“发送到画布”,整套节点配置会被直接打包投放到画布并自动完成连线,彻底告别一个个拉节点和连线的折磨。
|
||||
<details>
|
||||
<summary><b>📖 点击展开:标准操作指南(图文步骤)</b></summary>
|
||||
|
||||
### 核心特性三:深度扫描与元数据管理
|
||||
<br/>
|
||||
|
||||
* **O(1) 极速哈希提取**:无需计算几十 GB 模型的完整哈希。插件直接解析 `.safetensors` 文件头,瞬间截获 Hash 值,并自动去 C站 下载对应的 `.info` 说明与预览图。
|
||||
* **脱机张量推断(万能 Base 提取)**:即便是不在 C站 上的私模或其它平台模型,插件会强行解析网络结构(如发现 `double_blocks` 则识别为 Flux),在本地生成虚拟配置。让孤儿模型也能完美参与笔记本的 UI 筛选匹配。
|
||||
* **物理版本去重**:自动识别并清理不同名字但 Hash 完全相同的冗余 `.safetensors` 文件。
|
||||
* **快捷详情查阅**:在模型页面可以直接查看模型使用说明,并支持**一键复制触发词 (Trigger Words)**。
|
||||
#### 1. 顶部主工作区导航 (三大顶栏视图)
|
||||
插件顶部居中提供 3 个核心工作区入口:
|
||||
* **模型 (📦)**:主模型浏览器。左侧为模型文件夹分类树(`checkpoints`、`unet`、`loras` 等),中间展示模型卡片网格、封面图预览与触发词。
|
||||
* **图库 (🖼️)**:出图历史浏览器。原生读取本地 `output` 目录。顶部搜索栏支持输入提示词、模型名、LoRA、seed、文件名或模型哈希,回车即可生成独立的搜索标签块,点击标签块上的 `×` 可快速移除该过滤项;直接将图片拖拽至 ComfyUI 画布可原地恢复内嵌工作流。
|
||||
* **工作流 (🪡)**:工作流配方工坊。提供 `[全部]`、`[完整工作流]`、`[局部子图]` 子过滤器与标签筛选;点击右上角 `保存当前工作流` 即可将当前画布打包为配方;直接按住配方卡片拖拽到 ComfyUI 画布空白处释放即可立即载入。
|
||||
|
||||
### 其他实用功能
|
||||
#### 2. 底部导航与实用工具箱 (🧰 第一个图标:实用工具箱)
|
||||
* **布局结构**:侧边栏左下角常驻 6 个固定图标按钮:
|
||||
1. **实用工具箱 (🧰)**:左侧首个图标,点击展开工具箱浮窗面板。
|
||||
2. **扫描向导 (🔄)**:直达本地模型扫描与哈希/元数据建立。
|
||||
3. **模型医生 (🩺)**:直达工作流爆红诊断与缺失模型自动自愈。
|
||||
4. **节点助手 (🤖)**:直达画布选中节点模型信息查看、可视化换模与参数预设注入。
|
||||
5. **素材库 (✨)**:直达素材资产管理与多态画布拖拽。
|
||||
6. **全局设置 (⚙️)**:最右侧齿轮图标,直达全局偏好设置。
|
||||
* **实用工具箱浮窗**:点击首个图标(**🧰 实用工具箱**)打开工具箱窗口,集中收纳了未在底栏常驻的 5 大扩展工具:
|
||||
1. **导入导出 (⇄)**:无损 AMB 格式工作流分享码导入与导出,支持从文本分享码原地解析还原画布,或将当前画布打包生成带校验哈希的分享码。
|
||||
2. **提示词工坊 (🎛️)**:模块化提示词积木组装台,支持从出图历史或素材中抽取词卡并分块混音、正负词排序与组合。
|
||||
3. **翻译助手 (🌐)**:内置中英双向提示词互译工具,无需离开 ComfyUI 即可完成提示词中英翻译。
|
||||
4. **模型来源 (🔗)**:检视当前工作流或模型库中模型的在线下载来源(Civitai、HuggingFace、Liblib、ModelScope),支持一键在画布生成包含模型来源信息的 Note 说明节点。
|
||||
5. **提示词笔记 (📑)**:保存在 ComfyUI 用户目录(`workflows/anomalous_notebooks`)下的轻量提示词草稿本,用于随时记录、整理与复用常用提示词片段。
|
||||
|
||||
* **侧边栏停靠与快捷投放**:支持将插件停靠在侧边栏。点击模型卡片右上角的 **`+`** 号图标,即可瞬间将该模型的加载器 (Loader) 节点拍到画布上。
|
||||
* **前端极致性能**:不安装 Node.js,列表滚动性能极佳。提供节能模式(悬浮播放)与自动播放开关。
|
||||
* **原生生成图库 (Gallery)**:无缝浏览 output 文件夹的历史记录,无限懒加载,支持将图片直接拖拽至画布瞬间还原工作流。
|
||||
* **内置空间管理**:支持在界面安全删除模型,并自动清理配套的垃圾信息文件及预览图。
|
||||
#### 3. 扫描向导 (🔄 扫描向导)
|
||||
* **入口位置**:底部快捷栏第 2 个图标。
|
||||
* **操作步骤**:首次使用或添加新模型后打开,选择扫描范围(全部路径、仅缺失元数据或自定义目录)并执行,系统将自动建立本地模型库并拉取 C 站封面、标签与架构信息。
|
||||
|
||||
### 安装指南
|
||||
#### 4. 拯救爆红 (🩺 模型医生)
|
||||
* **入口位置**:底部快捷栏第 3 个图标。
|
||||
* **操作步骤**:载入他人工作流或图片出现红框缺失报错时,点击模型医生即可自动比对本地模型哈希与字节大小,一键批量映射为本地正确路径。卡片上的“查看哈希”可展开内嵌指纹与磁盘文件的 SHA256 逐项比对面板。
|
||||
|
||||
请打开命令行终端,进入 ComfyUI 的 `custom_nodes` 文件夹,执行以下命令即可完成安装:
|
||||
#### 5. 选中交互与预设 (🤖 节点助手)
|
||||
* **入口位置**:底部快捷栏第 4 个图标。
|
||||
* **动作功能**:在画布选中模型节点后,可在“动作”页查看高清预览图与触发词,支持看图一键替换模型或在兼容链路前后插入 LoRA。
|
||||
* **参数预设**:切换至“参数预设”页,可读取工作流配方中同类型节点的保存参数并一键注入画布(自动保留种子等易变数值)。
|
||||
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/your-username/Anomalous_Model_Browser.git
|
||||
```
|
||||
*(提示:克隆完成后,重启 ComfyUI 即可加载插件。你也可以直接在 ComfyUI Manager 中搜索 `Anomalous Model Browser` 并一键安装!)*
|
||||
#### 6. 统一素材库与多态画布拖拽 (✨ 素材库与多态拖拽)
|
||||
* **入口位置**:底部快捷栏第 5 个图标。
|
||||
* **保存素材**:在出图图库或配方详情中打开任意生成 PNG 的参数面板,可一键将图片、完整工作流、模型依赖与可复用节点参数打包收藏。
|
||||
* **多态画布拖拽**:
|
||||
- 将工作流素材直接拖拽至空白画布,即可原地恢复并打开完整工作流。
|
||||
- 将纯提示词素材拖拽至空白画布,会自动创建带有正负区分色(深红 `#532323` 为负向 / 墨绿 `#235327` 为正向)的原生 `CLIPTextEncode` 节点。
|
||||
- 将素材拖拽至画布已有节点上时,会自动通过语义探针将匹配参数注入目标节点中。
|
||||
* **本地存储**:素材存储于 ComfyUI 用户目录下的 `workflows/anomalous_materials`,支持原子写入。
|
||||
|
||||
### 快速上手 (Quick Start)
|
||||
#### 7. 全局设置面板 (⚙️ 设置)
|
||||
* **入口位置**:侧边栏左下角最右侧第 6 个图标(**齿轮图标 ⚙️**)。
|
||||
* **个性调节**:支持中英文界面切换、字体大小缩放、视频封面悬停/常开播放、缩略图优化与目录黑名单管理。
|
||||
|
||||
**初始配置 (必做):**
|
||||
1. **第一步**:点击界面主菜单或侧边栏的 **Model Browser** 按钮打开面板。
|
||||
2. **第二步**:务必运行一次**【扫描】**(可全局或分文件夹扫描),建立本地模型哈希数据库。此后保存的工作流才会具备自愈基因。
|
||||
> [!WARNING]
|
||||
> **测试功能数据安全提醒:** 工作流配方、素材库与参数预设目前属于活跃测试阶段,更新插件前建议备份 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters` 文件夹。
|
||||
|
||||
**常规使用 (浏览与添加模型):**
|
||||
- 浏览模型库,点击任意模型卡片右上角的 **`+` (加号)** 图标,即可将该模型的加载节点直接丢到画布上。
|
||||
</details>
|
||||
|
||||
**修复报错工作流:**
|
||||
1. **第一步**:将带有 Hash 信息的图片(须在使用本插件且扫描建库后生成)拖入 ComfyUI 画布。
|
||||
2. **第二步**:若发生模型路径不匹配导致的飘红报错,点击提示的 **一键修复工作流** 按钮,等待插件在本地检索 Hash 并自动恢复节点连接。
|
||||
---
|
||||
|
||||
### 📝 License & Branding (开源与品牌声明)
|
||||
|
||||
* **Code License (代码授权)**: The source code is released under the [MIT License](LICENSE). 本项目源代码基于 MIT 许可证开源,可自由使用、修改与分发。
|
||||
* **Branding & Trademarks (商标与品牌保护)**: The name **Anomalous Model Browser** and the official logo identify official releases. Forks should use distinct names/branding. 详见 [Trademark and Brand Policy](TRADEMARKS.md)。
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Trademark and Brand Policy / 商标与品牌政策
|
||||
|
||||
This policy separates the project's open-source copyright license from use of its brand identifiers. It does not change or add restrictions to the MIT License.
|
||||
|
||||
本政策用于区分项目的开源版权许可与品牌标识的使用方式,不修改 MIT 许可证,也不向 MIT 许可证附加限制。
|
||||
|
||||
## Open-source materials / 开源内容
|
||||
|
||||
Unless a file states otherwise, the source code, documentation, stylesheets, ordinary interface resources, and other copyrightable materials distributed in this repository are licensed under the [MIT License](LICENSE). The MIT License permits use, copying, modification, publication, distribution, sublicensing, and sale subject to its notice requirement.
|
||||
|
||||
除非文件中另有明确说明,本仓库发布的源代码、文档、样式表、普通界面资源以及其他受版权保护的内容均采用 [MIT License](LICENSE) 授权。遵守许可证中的版权及许可声明保留要求后,MIT 许可证允许使用、复制、修改、发布、分发、再许可和销售。
|
||||
|
||||
## Project marks / 项目标识
|
||||
|
||||
The following are project brand identifiers (the "Project Marks"):
|
||||
|
||||
- the name **Anomalous Model Browser**;
|
||||
- stylized presentations of that name used to identify an official release;
|
||||
- any graphic expressly identified by the repository owner as the official project logo.
|
||||
|
||||
以下内容属于项目品牌标识(“项目标识”):
|
||||
|
||||
- 名称 **Anomalous Model Browser**;
|
||||
- 用于识别官方版本的该名称之视觉化表现;
|
||||
- 由仓库所有者明确指定为官方项目 Logo 的图形。
|
||||
|
||||
The Project Marks identify the source of official releases. Permission to use copyrightable material under the MIT License does not by itself imply that a modified distribution is an official release or is endorsed by the project owner.
|
||||
|
||||
项目标识用于辨认官方发布来源。依据 MIT 许可证使用受版权保护的内容,并不意味着修改后的发行版属于官方版本或获得项目所有者认可。
|
||||
|
||||
## Uses that do not require permission / 无需许可的使用
|
||||
|
||||
You may, without separate permission:
|
||||
|
||||
- truthfully refer to the official project by name, including in articles, reviews, compatibility statements, and dependency notices;
|
||||
- link to the official repository;
|
||||
- state that your software is based on, derived from, or compatible with Anomalous Model Browser, provided the statement is accurate and does not imply sponsorship or endorsement;
|
||||
- redistribute an unmodified official release with its original notices and branding intact;
|
||||
- use screenshots of the official software for documentation, discussion, reviews, or tutorials, provided the context does not imply that a modified product is official.
|
||||
|
||||
您无需另行取得许可即可:
|
||||
|
||||
- 在文章、评测、兼容性说明和依赖声明中如实提及官方项目名称;
|
||||
- 链接到官方仓库;
|
||||
- 在内容准确且不暗示赞助或认可的前提下,说明您的软件基于、衍生自或兼容 Anomalous Model Browser;
|
||||
- 在保留原始声明和品牌标识的情况下,重新分发未经修改的官方版本;
|
||||
- 将官方软件截图用于文档、讨论、评测或教程,但不得借此暗示修改后的产品属于官方版本。
|
||||
|
||||
## Publicly distributed modified versions / 公开分发的修改版本
|
||||
|
||||
If you publicly distribute a modified version or fork:
|
||||
|
||||
- use a distinct primary product name and a distinct logo so users can readily distinguish it from the official project;
|
||||
- identify it clearly as an unofficial fork or modified version;
|
||||
- you may use wording such as "Based on Anomalous Model Browser" to describe its origin;
|
||||
- preserve the copyright and permission notices required by the MIT License.
|
||||
|
||||
如果您公开分发修改版本或分支:
|
||||
|
||||
- 应使用不同的主要产品名称和不同的 Logo,使用户能够清楚地区分其与官方项目;
|
||||
- 应明确说明它属于非官方分支或修改版本;
|
||||
- 可以使用“基于 Anomalous Model Browser”等文字如实说明来源;
|
||||
- 必须保留 MIT 许可证要求的版权及许可声明。
|
||||
|
||||
These branding guidelines do not require renaming private modifications that are not distributed to others.
|
||||
|
||||
本品牌指引不要求仅供个人使用、未向他人分发的私有修改版本改名。
|
||||
|
||||
## Uses requiring permission / 需要许可的使用
|
||||
|
||||
Unless applicable law permits the use independently, written permission from the repository owner is required to:
|
||||
|
||||
- present a modified fork as an official release;
|
||||
- use the Project Marks as the primary name or logo of a publicly distributed modified product;
|
||||
- use a confusingly similar name, logo, repository listing, domain, or package identity that is likely to make users believe a third-party distribution is official;
|
||||
- state or imply sponsorship, certification, partnership, or endorsement by the project owner when none exists.
|
||||
|
||||
除非适用法律独立允许该使用,否则下列情况需要获得仓库所有者的书面许可:
|
||||
|
||||
- 将修改后的分支作为官方版本发布;
|
||||
- 将项目标识用作公开分发之修改产品的主要名称或 Logo;
|
||||
- 使用可能使用户误认为第三方发行版属于官方版本的近似名称、Logo、仓库条目、域名或软件包标识;
|
||||
- 在不存在相关关系时,宣称或暗示项目所有者提供了赞助、认证、合作或认可。
|
||||
|
||||
## Questions and permissions / 咨询与授权
|
||||
|
||||
For questions or permission requests, contact the repository owner through the official repository: <https://github.com/DemonGatanjieu/Anomalous_Model_Browser>.
|
||||
|
||||
如需咨询或申请许可,请通过官方仓库联系仓库所有者:<https://github.com/DemonGatanjieu/Anomalous_Model_Browser>。
|
||||
|
||||
This policy is a project branding notice, not legal advice. Rights that apply independently under local law are not restricted by this document.
|
||||
|
||||
本政策是项目品牌说明,不构成法律意见,也不限制依据当地法律独立享有的权利。
|
||||
@@ -1,988 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import threading
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
def get_metadata(file_path):
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
metadata = {
|
||||
"name": os.path.basename(base_path),
|
||||
"description": "",
|
||||
"notes": "",
|
||||
"trainedWords": [],
|
||||
"baseModel": "",
|
||||
"civitai_url": "",
|
||||
"hash": ""
|
||||
}
|
||||
|
||||
info_files = [f"{base_path}.civitai.info", f"{base_path}.info"]
|
||||
for info_file in info_files:
|
||||
if os.path.exists(info_file):
|
||||
try:
|
||||
with open(info_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
name = data.get("name", "")
|
||||
if "model" in data and isinstance(data["model"], dict):
|
||||
model_name = data["model"].get("name", "")
|
||||
name = f"{model_name} - {name}".strip(' -')
|
||||
|
||||
description = data.get("description", "") or ""
|
||||
if not description and "model" in data and isinstance(data["model"], dict):
|
||||
description = data["model"].get("description", "")
|
||||
notes = data.get("notes", "")
|
||||
trained_words = data.get("trainedWords", [])
|
||||
base_model = data.get("baseModel", "")
|
||||
|
||||
model_id = data.get("modelId", "")
|
||||
version_id = data.get("id", "")
|
||||
civitai_url = ""
|
||||
if model_id:
|
||||
# Handle Civitai's new mature content policy
|
||||
nsfw_level = data.get("nsfwLevel", 1)
|
||||
is_nsfw = False
|
||||
if "model" in data and isinstance(data["model"], dict):
|
||||
is_nsfw = data["model"].get("nsfw", False)
|
||||
|
||||
domain = "civitai.red" if (nsfw_level > 1 or is_nsfw) else "civitai.com"
|
||||
civitai_url = f"https://{domain}/models/{model_id}"
|
||||
if version_id:
|
||||
civitai_url += f"?modelVersionId={version_id}"
|
||||
|
||||
|
||||
hash_val = ""
|
||||
for file_info in data.get("files", []):
|
||||
if isinstance(file_info, dict):
|
||||
hashes = file_info.get("hashes", {})
|
||||
if isinstance(hashes, dict) and "SHA256" in hashes:
|
||||
hash_val = hashes["SHA256"]
|
||||
break
|
||||
|
||||
metadata["name"] = name if name else metadata["name"]
|
||||
metadata["description"] = description
|
||||
metadata["notes"] = notes
|
||||
metadata["trainedWords"] = trained_words
|
||||
metadata["baseModel"] = base_model
|
||||
metadata["civitai_url"] = civitai_url
|
||||
metadata["hash"] = hash_val
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
async def api_get_folders(request):
|
||||
types = ['checkpoints', 'loras', 'unet', 'diffusion_models']
|
||||
result = []
|
||||
seen_dirs = set()
|
||||
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
except Exception:
|
||||
continue
|
||||
if not paths:
|
||||
continue
|
||||
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if not os.path.exists(base_dir):
|
||||
continue
|
||||
real_path = os.path.realpath(base_dir)
|
||||
if real_path in seen_dirs:
|
||||
continue
|
||||
seen_dirs.add(real_path)
|
||||
|
||||
tree = {}
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
has_models = any(f.endswith(('.safetensors', '.ckpt', '.pt')) for f in files)
|
||||
rel = os.path.relpath(root, base_dir)
|
||||
if rel == '.':
|
||||
rel = '/'
|
||||
else:
|
||||
rel = '/' + rel.replace('\\', '/')
|
||||
tree[rel] = {
|
||||
"path": rel,
|
||||
"name": os.path.basename(root) if rel != '/' else '[Root]',
|
||||
"has_models": has_models,
|
||||
"model_count": sum(1 for f in files if f.endswith(('.safetensors', '.ckpt', '.pt')))
|
||||
}
|
||||
|
||||
label = t.capitalize()
|
||||
if path_idx > 0:
|
||||
label += f" ({os.path.basename(base_dir)})"
|
||||
|
||||
result.append({
|
||||
"type": t,
|
||||
"path_idx": path_idx,
|
||||
"label": label,
|
||||
"folders": tree
|
||||
})
|
||||
|
||||
return web.json_response({"folders": result})
|
||||
|
||||
|
||||
async def api_get_models(request):
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
subfolder = request.query.get('subfolder', '/')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
return web.json_response({"models": []})
|
||||
if not paths or path_idx >= len(paths):
|
||||
return web.json_response({"models": []})
|
||||
|
||||
if '..' in subfolder:
|
||||
return web.Response(status=400, text='Invalid subfolder')
|
||||
|
||||
base_dir = paths[path_idx]
|
||||
if subfolder == '/':
|
||||
target_dir = base_dir
|
||||
rel_subfolder = ""
|
||||
else:
|
||||
rel_subfolder = subfolder.strip('/')
|
||||
target_dir = os.path.join(base_dir, rel_subfolder)
|
||||
|
||||
if not os.path.exists(target_dir):
|
||||
return web.json_response({"models": []})
|
||||
|
||||
models = []
|
||||
try:
|
||||
entries = os.listdir(target_dir)
|
||||
except Exception:
|
||||
return web.json_response({"models": []})
|
||||
|
||||
for f in sorted(entries):
|
||||
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'):
|
||||
file_path = os.path.join(target_dir, f)
|
||||
if not os.path.isfile(file_path):
|
||||
continue
|
||||
|
||||
meta = get_metadata(file_path)
|
||||
|
||||
base_name = os.path.splitext(f)[0]
|
||||
preview_file = None
|
||||
for ext in ['.preview.png', '.png', '.jpg', '.jpeg', '.webp', '.mp4', '.webm']:
|
||||
if os.path.exists(os.path.join(target_dir, base_name + ext)):
|
||||
preview_file = base_name + ext
|
||||
break
|
||||
|
||||
preview_url = ""
|
||||
if preview_file:
|
||||
q_type = urllib.parse.quote(folder_type)
|
||||
q_idx = str(path_idx)
|
||||
q_sub = urllib.parse.quote(rel_subfolder)
|
||||
q_file = urllib.parse.quote(preview_file)
|
||||
preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}"
|
||||
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
size_mb = round(size_bytes / (1024 * 1024), 1)
|
||||
except Exception:
|
||||
size_mb = 0
|
||||
size_bytes = 0
|
||||
|
||||
models.append({
|
||||
"filename": f,
|
||||
"size_mb": size_mb,
|
||||
"size_bytes": size_bytes,
|
||||
"metadata": meta,
|
||||
"preview_url": preview_url
|
||||
})
|
||||
|
||||
return web.json_response({"models": models})
|
||||
|
||||
|
||||
async def api_serve_image(request):
|
||||
"""Dedicated image serving endpoint for model preview images."""
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
subfolder = request.query.get('subfolder', '')
|
||||
filename = request.query.get('filename', '')
|
||||
|
||||
if not filename or '..' in filename or '..' in subfolder:
|
||||
return web.Response(status=400, text='Invalid request')
|
||||
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
return web.Response(status=404, text='Folder type not found')
|
||||
if not paths or path_idx >= len(paths):
|
||||
return web.Response(status=404, text='Folder type not found')
|
||||
|
||||
base_dir = paths[path_idx]
|
||||
if subfolder:
|
||||
file_path = os.path.join(base_dir, subfolder, filename)
|
||||
else:
|
||||
file_path = os.path.join(base_dir, filename)
|
||||
|
||||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||||
return web.Response(status=404, text='Image not found')
|
||||
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
content_types = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif'
|
||||
}
|
||||
content_type = content_types.get(ext, 'application/octet-stream')
|
||||
|
||||
return web.FileResponse(file_path, headers={'Content-Type': content_type})
|
||||
|
||||
|
||||
|
||||
async def api_scan_status(request):
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
subfolder = request.query.get('subfolder', '/')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
return web.json_response({"scanning": False})
|
||||
if not paths or path_idx >= len(paths) or '..' in subfolder:
|
||||
return web.json_response({"scanning": False})
|
||||
|
||||
base_dir = paths[path_idx]
|
||||
if subfolder == '/':
|
||||
target_dir = base_dir
|
||||
else:
|
||||
target_dir = os.path.join(base_dir, subfolder.strip('/'))
|
||||
|
||||
marker_file = os.path.join(target_dir, '.scan_in_progress')
|
||||
result_file = os.path.join(target_dir, '.scan_result.json')
|
||||
scanning = os.path.exists(marker_file)
|
||||
|
||||
data = {"scanning": scanning}
|
||||
if not scanning and os.path.exists(result_file):
|
||||
try:
|
||||
with open(result_file, 'r', encoding='utf-8') as f:
|
||||
data["result"] = __import__('json').load(f)
|
||||
os.remove(result_file)
|
||||
except:
|
||||
pass
|
||||
|
||||
return web.json_response(data)
|
||||
|
||||
async def api_scan_folder(request):
|
||||
"""Launches the scraper in the background for a specific directory."""
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
subfolder = request.query.get('subfolder', '/')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
|
||||
if '..' in subfolder:
|
||||
return web.json_response({"status": "error", "message": "Invalid subfolder"})
|
||||
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
return web.json_response({"status": "error", "message": "Invalid folder type"})
|
||||
|
||||
if not paths or path_idx >= len(paths):
|
||||
return web.json_response({"status": "error", "message": "Invalid path index"})
|
||||
|
||||
base_dir = paths[path_idx]
|
||||
if subfolder == '/':
|
||||
target_dir = base_dir
|
||||
else:
|
||||
target_dir = os.path.join(base_dir, subfolder.strip('/'))
|
||||
|
||||
if not os.path.exists(target_dir):
|
||||
return web.json_response({"status": "error", "message": "Directory does not exist"})
|
||||
|
||||
# Get the scraper path relative to this script
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
scraper_path = os.path.join(script_dir, "scraper.py")
|
||||
|
||||
if not os.path.exists(scraper_path):
|
||||
return web.json_response({"status": "error", "message": "scraper.py not found in extension directory"})
|
||||
|
||||
print(f"[Anomalous Browser] Starting background scan for: {target_dir}")
|
||||
|
||||
try:
|
||||
marker_file = os.path.join(target_dir, '.scan_in_progress')
|
||||
with open(marker_file, 'w') as f: f.write('1')
|
||||
def run_bg():
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, scraper_path, target_dir],
|
||||
cwd=script_dir,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
finally:
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
folder_paths.filename_list_cache.clear()
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
folder_paths.cache_helper.clear()
|
||||
|
||||
if os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except: pass
|
||||
|
||||
threading.Thread(target=run_bg, daemon=True).start()
|
||||
|
||||
return web.json_response({"status": "ok", "message": "Scan started in background. Check console for details."})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_scan_all(request):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
scraper_path = os.path.join(script_dir, "scraper.py")
|
||||
marker_file = os.path.join(script_dir, '.global_scan_in_progress')
|
||||
|
||||
if os.path.exists(marker_file):
|
||||
return web.json_response({"status": "error", "message": "Global scan already in progress"})
|
||||
|
||||
try:
|
||||
with open(marker_file, 'w') as f: f.write('1')
|
||||
|
||||
def run_global_bg():
|
||||
try:
|
||||
types = ['checkpoints', 'loras', 'unet', 'diffusion_models']
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
if not paths: continue
|
||||
for base_dir in paths:
|
||||
if not os.path.exists(base_dir): continue
|
||||
print(f"[Anomalous Browser] Global scan processing: {base_dir}")
|
||||
subprocess.run(
|
||||
[sys.executable, scraper_path, base_dir, "--skip-rename", "--skip-media"],
|
||||
cwd=script_dir,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Anomalous Browser] Global scan error on {t}: {e}")
|
||||
finally:
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
folder_paths.filename_list_cache.clear()
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
folder_paths.cache_helper.clear()
|
||||
|
||||
if os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except: pass
|
||||
|
||||
threading.Thread(target=run_global_bg, daemon=True).start()
|
||||
return web.json_response({"status": "ok", "message": "Global scan started"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_global_scan_status(request):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
marker_file = os.path.join(script_dir, '.global_scan_in_progress')
|
||||
return web.json_response({"scanning": os.path.exists(marker_file)})
|
||||
async def api_clear_cache(request):
|
||||
try:
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
folder_paths.filename_list_cache.clear()
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
folder_paths.cache_helper.clear()
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_get_config(request):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
has_key = False
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
has_key = bool(cfg.get("CIVITAI_API_KEY", "").strip())
|
||||
except:
|
||||
pass
|
||||
return web.json_response({"has_api_key": has_key})
|
||||
|
||||
async def api_save_config(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
api_key = data.get("api_key", "").strip()
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
cfg = {}
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
cfg["CIVITAI_API_KEY"] = api_key
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, indent=4)
|
||||
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
async def api_delete_model(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
folder_type = data.get('type', 'checkpoints')
|
||||
subfolder = data.get('subfolder', '/')
|
||||
filename = data.get('filename', '')
|
||||
try:
|
||||
path_idx = int(data.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
|
||||
if not filename or '..' in filename or '..' in subfolder:
|
||||
return web.json_response({"status": "error", "message": "Invalid request parameters"})
|
||||
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
return web.json_response({"status": "error", "message": "Invalid folder type"})
|
||||
|
||||
if not paths or path_idx >= len(paths):
|
||||
return web.json_response({"status": "error", "message": "Invalid path index"})
|
||||
|
||||
base_dir = paths[path_idx]
|
||||
if subfolder == '/':
|
||||
target_dir = base_dir
|
||||
else:
|
||||
target_dir = os.path.join(base_dir, subfolder.strip('/'))
|
||||
|
||||
model_path = os.path.join(target_dir, filename)
|
||||
if not os.path.exists(model_path):
|
||||
return web.json_response({"status": "error", "message": "Model file not found"})
|
||||
|
||||
# 1. 优先尝试删除你点击的主模型文件
|
||||
try:
|
||||
os.remove(model_path)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "being used" in error_msg or "WinError 32" in error_msg or "Permission" in error_msg:
|
||||
error_msg = "文件被占用 (正在被 ComfyUI 使用)。请先重启 ComfyUI 或在工作流中卸载该模型后再删除!"
|
||||
return web.json_response({"status": "error", "message": f"主模型删除失败: {error_msg}"})
|
||||
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
|
||||
# 2. 主模型成功删除后,再清理配套的垃圾文件
|
||||
associated_exts = [
|
||||
'.safetensors', '.ckpt', '.pt', '.bin',
|
||||
'.info', '.civitai.info',
|
||||
'.png', '.preview.png', '.jpg', '.jpeg', '.webp', '.gif',
|
||||
'.json', '.txt', '.yaml'
|
||||
]
|
||||
|
||||
deleted_files = [filename]
|
||||
for ext in associated_exts:
|
||||
file_to_del = os.path.join(target_dir, base_name + ext)
|
||||
if file_to_del == model_path:
|
||||
continue
|
||||
if os.path.exists(file_to_del) and os.path.isfile(file_to_del):
|
||||
try:
|
||||
os.remove(file_to_del)
|
||||
deleted_files.append(base_name + ext)
|
||||
except Exception as e:
|
||||
print(f"[Anomalous Browser] Warning: Failed to delete {file_to_del}: {e}")
|
||||
|
||||
# 3. 修正:前端期待的成功状态是 "success" 而不是 "ok"
|
||||
return web.json_response({"status": "success", "deleted": deleted_files})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
async def api_clean_civitai_info(request):
|
||||
try:
|
||||
types = ['checkpoints', 'loras', 'unet', 'diffusion_models']
|
||||
deleted_count = 0
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
except Exception:
|
||||
continue
|
||||
if not paths: continue
|
||||
|
||||
for base_dir in paths:
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.endswith('.civitai.info'):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
os.remove(file_path)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return web.json_response({"status": "success", "count": deleted_count})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
async def api_compatible_models(request):
|
||||
base_model = request.query.get('base_model', '')
|
||||
target_type = request.query.get('target_type', 'loras')
|
||||
|
||||
if not base_model:
|
||||
return web.json_response({"models": []})
|
||||
|
||||
target_types = [t.strip() for t in target_type.split(',')]
|
||||
compatible_models = []
|
||||
seen_files = set()
|
||||
|
||||
for t in target_types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not paths:
|
||||
continue
|
||||
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if not os.path.exists(base_dir):
|
||||
continue
|
||||
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for f in files:
|
||||
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'):
|
||||
file_path = os.path.join(root, f)
|
||||
real_path = os.path.realpath(file_path)
|
||||
if real_path in seen_files:
|
||||
continue
|
||||
seen_files.add(real_path)
|
||||
|
||||
meta = get_metadata(file_path)
|
||||
m_bm = str(meta.get("baseModel", "")).strip().lower().replace(" ", "")
|
||||
req_bm = str(base_model).strip().lower().replace(" ", "")
|
||||
|
||||
if req_bm and m_bm and (req_bm in m_bm or m_bm in req_bm):
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.':
|
||||
rel_subfolder = '/'
|
||||
else:
|
||||
rel_subfolder = '/' + rel_subfolder.replace('\\', '/')
|
||||
|
||||
base_name = os.path.splitext(f)[0]
|
||||
preview_file = None
|
||||
for ext in ['.preview.png', '.png', '.jpg', '.jpeg', '.webp', '.mp4', '.webm']:
|
||||
if os.path.exists(os.path.join(root, base_name + ext)):
|
||||
preview_file = base_name + ext
|
||||
break
|
||||
|
||||
preview_url = ""
|
||||
if preview_file:
|
||||
q_type = urllib.parse.quote(t)
|
||||
q_idx = str(path_idx)
|
||||
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder != '/' else ""
|
||||
q_file = urllib.parse.quote(preview_file)
|
||||
preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}"
|
||||
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
size_mb = round(size_bytes / (1024 * 1024), 1)
|
||||
except Exception:
|
||||
size_mb = 0
|
||||
size_bytes = 0
|
||||
|
||||
compatible_models.append({
|
||||
"type": t,
|
||||
"path_idx": path_idx,
|
||||
"subfolder": rel_subfolder,
|
||||
"filename": f,
|
||||
"size_mb": size_mb,
|
||||
"size_bytes": size_bytes,
|
||||
"preview_url": preview_url,
|
||||
"metadata": meta
|
||||
})
|
||||
|
||||
return web.json_response({"models": compatible_models})
|
||||
|
||||
async def api_get_notebooks(request):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
nb_dir = os.path.join(script_dir, "notebooks")
|
||||
if not os.path.exists(nb_dir):
|
||||
os.makedirs(nb_dir)
|
||||
|
||||
notebooks = []
|
||||
for f in os.listdir(nb_dir):
|
||||
if f.endswith('.json'):
|
||||
try:
|
||||
with open(os.path.join(nb_dir, f), 'r', encoding='utf-8') as file:
|
||||
data = json.load(file)
|
||||
notebooks.append({
|
||||
"filename": f,
|
||||
"name": data.get("name", f.replace('.json', '')),
|
||||
"data": data
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({"notebooks": notebooks})
|
||||
|
||||
async def api_save_notebook(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename", "")
|
||||
if not filename or '..' in filename:
|
||||
return web.json_response({"status": "error", "message": "Invalid filename"})
|
||||
if not filename.endswith('.json'):
|
||||
filename += '.json'
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
nb_dir = os.path.join(script_dir, "notebooks")
|
||||
if not os.path.exists(nb_dir):
|
||||
os.makedirs(nb_dir)
|
||||
|
||||
file_path = os.path.join(nb_dir, filename)
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data.get("data", {}), f, indent=4, ensure_ascii=False)
|
||||
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_delete_notebook(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename", "")
|
||||
if not filename or '..' in filename:
|
||||
return web.json_response({"status": "error", "message": "Invalid filename"})
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
nb_dir = os.path.join(script_dir, "notebooks")
|
||||
file_path = os.path.join(nb_dir, filename)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
return web.json_response({"status": "success"})
|
||||
return web.json_response({"status": "error", "message": "File not found"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_translate(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
text = data.get("text", "")
|
||||
tl = data.get("target_lang", "zh-CN")
|
||||
if not text:
|
||||
return web.json_response({"translated": ""})
|
||||
|
||||
# Try DeepL if API key exists
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
deepl_key = ""
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
deepl_key = cfg.get("DEEPL_API_KEY", "").strip()
|
||||
except:
|
||||
pass
|
||||
|
||||
if deepl_key:
|
||||
deepl_map = { "zh-CN": "ZH", "en": "EN", "ja": "JA", "ko": "KO", "fr": "FR", "de": "DE", "es": "ES", "ru": "RU" }
|
||||
d_tl = deepl_map.get(tl, "EN")
|
||||
url = "https://api-free.deepl.com/v2/translate" if ":fx" in deepl_key else "https://api.deepl.com/v2/translate"
|
||||
payload = urllib.parse.urlencode({
|
||||
"auth_key": deepl_key,
|
||||
"text": text,
|
||||
"target_lang": d_tl
|
||||
}).encode('utf-8')
|
||||
req = urllib.request.Request(url, data=payload)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
return web.json_response({"translated": result["translations"][0]["text"]})
|
||||
else:
|
||||
# Fallback to Google Translate (free, no key)
|
||||
url = f"https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl={tl}&dt=t&q={urllib.parse.quote(text)}"
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
# Google Translate returns a list of fragments
|
||||
translated_text = "".join([part[0] for part in result[0]])
|
||||
return web.json_response({"translated": translated_text})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Translate Error: {e}")
|
||||
return web.json_response({"translated": text, "error": str(e)})
|
||||
|
||||
|
||||
async def api_base_models(request):
|
||||
target_types = ['checkpoints', 'unet', 'diffusion_models', 'loras']
|
||||
base_models = set()
|
||||
seen_files = set()
|
||||
|
||||
for t in target_types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
except Exception:
|
||||
continue
|
||||
if not paths: continue
|
||||
|
||||
for base_dir in paths:
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for f in files:
|
||||
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'):
|
||||
file_path = os.path.join(root, f)
|
||||
real_path = os.path.realpath(file_path)
|
||||
if real_path in seen_files: continue
|
||||
seen_files.add(real_path)
|
||||
|
||||
meta = get_metadata(file_path)
|
||||
m_bm = meta.get("baseModel", "")
|
||||
if m_bm and str(m_bm).strip():
|
||||
# Remove typical generic strings that might pollute
|
||||
clean_bm = str(m_bm).strip()
|
||||
base_models.add(clean_bm)
|
||||
|
||||
return web.json_response({"base_models": sorted(list(base_models))})
|
||||
|
||||
async def api_get_gallery_images(request):
|
||||
try:
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if not os.path.exists(output_dir):
|
||||
return web.json_response({"images": [], "total": 0, "page": 1, "pages": 0})
|
||||
|
||||
page = int(request.query.get('page', 1))
|
||||
limit = int(request.query.get('limit', 50))
|
||||
|
||||
valid_exts = {'.png', '.jpg', '.jpeg', '.webp', '.gif'}
|
||||
images = []
|
||||
|
||||
for root, dirs, files in os.walk(output_dir):
|
||||
for f in files:
|
||||
ext = os.path.splitext(f)[1].lower()
|
||||
if ext in valid_exts:
|
||||
rel_path = os.path.relpath(root, output_dir)
|
||||
subfolder = "" if rel_path == "." else rel_path.replace('\\', '/')
|
||||
full_path = os.path.join(root, f)
|
||||
try:
|
||||
mtime = os.path.getmtime(full_path)
|
||||
except:
|
||||
mtime = 0
|
||||
images.append({
|
||||
"filename": f,
|
||||
"subfolder": subfolder,
|
||||
"type": "output",
|
||||
"mtime": mtime
|
||||
})
|
||||
|
||||
# Sort by mtime descending (newest first)
|
||||
images.sort(key=lambda x: x['mtime'], reverse=True)
|
||||
|
||||
total = len(images)
|
||||
start_idx = (page - 1) * limit
|
||||
end_idx = start_idx + limit
|
||||
paginated_images = images[start_idx:end_idx]
|
||||
|
||||
return web.json_response({
|
||||
"images": paginated_images,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": (total + limit - 1) // limit if limit > 0 else 0
|
||||
})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def api_delete_gallery_image(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename")
|
||||
subfolder = data.get("subfolder", "")
|
||||
|
||||
if not filename or '..' in filename or '..' in subfolder:
|
||||
return web.json_response({"status": "error", "message": "Invalid parameters"})
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if subfolder:
|
||||
file_path = os.path.join(output_dir, subfolder, filename)
|
||||
else:
|
||||
file_path = os.path.join(output_dir, filename)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
return web.json_response({"status": "success"})
|
||||
else:
|
||||
return web.json_response({"status": "error", "message": "File not found"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
async def api_resolve_hash(request):
|
||||
target_hash = request.query.get("hash", "").strip().upper()
|
||||
size_str = request.query.get("size", "").strip()
|
||||
filename_query = request.query.get("filename", "").strip()
|
||||
target_size = int(size_str) if size_str.isdigit() else None
|
||||
|
||||
if not target_hash and not target_size and not filename_query:
|
||||
return web.json_response({"found": False})
|
||||
|
||||
types = ['checkpoints', 'loras', 'unet', 'diffusion_models']
|
||||
|
||||
# First pass: try exact hash match in .info files
|
||||
if target_hash:
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
except Exception:
|
||||
continue
|
||||
if not paths:
|
||||
continue
|
||||
|
||||
for base_dir in paths:
|
||||
if not os.path.exists(base_dir):
|
||||
continue
|
||||
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.endswith('.safetensors') or file.endswith('.ckpt') or file.endswith('.pt'):
|
||||
file_path = os.path.join(root, file)
|
||||
meta = get_metadata(file_path)
|
||||
if meta.get("hash", "").upper() == target_hash:
|
||||
# ComfyUI widget expects relative path from folder_paths base_dir
|
||||
rel_path = os.path.relpath(file_path, base_dir)
|
||||
if rel_path.startswith('.\\') or rel_path.startswith('./'):
|
||||
rel_path = rel_path[2:]
|
||||
rel_path = rel_path.replace('\\', '/')
|
||||
return web.json_response({
|
||||
"found": True,
|
||||
"type": t,
|
||||
"filename": rel_path
|
||||
})
|
||||
|
||||
# Second pass: Fallback size match
|
||||
if target_size is not None:
|
||||
size_matches = []
|
||||
seen_realpaths = set()
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
except Exception:
|
||||
continue
|
||||
if not paths:
|
||||
continue
|
||||
|
||||
for base_dir in paths:
|
||||
if not os.path.exists(base_dir):
|
||||
continue
|
||||
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.endswith('.safetensors') or file.endswith('.ckpt') or file.endswith('.pt'):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
if os.path.getsize(file_path) == target_size:
|
||||
real_p = os.path.realpath(file_path)
|
||||
if real_p in seen_realpaths:
|
||||
continue
|
||||
seen_realpaths.add(real_p)
|
||||
|
||||
rel_path = os.path.relpath(file_path, base_dir)
|
||||
if rel_path.startswith('.\\') or rel_path.startswith('./'):
|
||||
rel_path = rel_path[2:]
|
||||
rel_path = rel_path.replace('\\', '/')
|
||||
size_matches.append({
|
||||
"type": t,
|
||||
"filename": rel_path
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If exactly ONE file matches the size, it's a definitive match
|
||||
if len(size_matches) == 1:
|
||||
return web.json_response({
|
||||
"found": True,
|
||||
"type": size_matches[0]["type"],
|
||||
"filename": size_matches[0]["filename"],
|
||||
"matched_by_size": True
|
||||
})
|
||||
|
||||
return web.json_response({"found": False})
|
||||
|
||||
async def api_get_all_hashes(request):
|
||||
"""
|
||||
Returns a dictionary of all scanned models with their hash and size.
|
||||
Keyed by both relative path and basename for maximum frontend resilience.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
def fetch_all():
|
||||
hashes = {}
|
||||
types = ['checkpoints', 'loras', 'unet', 'diffusion_models']
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
if not paths: continue
|
||||
for base_dir in paths:
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.endswith('.safetensors') or file.endswith('.ckpt') or file.endswith('.pt'):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
except Exception:
|
||||
size_bytes = 0
|
||||
|
||||
meta = get_metadata(file_path)
|
||||
hash_val = ""
|
||||
if meta and meta.get("hash"):
|
||||
hash_val = meta["hash"]
|
||||
|
||||
rel_path = os.path.relpath(file_path, base_dir)
|
||||
if rel_path.startswith('.\\') or rel_path.startswith('./'):
|
||||
rel_path = rel_path[2:]
|
||||
rel_path = rel_path.replace('\\', '/')
|
||||
basename = os.path.basename(file_path)
|
||||
|
||||
val = {"hash": hash_val, "size": size_bytes}
|
||||
hashes[rel_path] = val
|
||||
hashes[basename] = val
|
||||
except Exception:
|
||||
pass
|
||||
return hashes
|
||||
|
||||
hashes = await asyncio.to_thread(fetch_all)
|
||||
return web.json_response(hashes)
|
||||
|
||||
def setup_routes(app):
|
||||
app.router.add_get('/anomalous/folders', api_get_folders)
|
||||
app.router.add_get('/anomalous/models', api_get_models)
|
||||
app.router.add_get('/anomalous/image', api_serve_image)
|
||||
app.router.add_post('/anomalous/scan', api_scan_folder)
|
||||
app.router.add_get('/anomalous/scan_status', api_scan_status)
|
||||
app.router.add_get('/anomalous/config', api_get_config)
|
||||
app.router.add_post('/anomalous/save_config', api_save_config)
|
||||
app.router.add_post('/anomalous/delete_model', api_delete_model)
|
||||
app.router.add_post('/anomalous/clean_civitai_info', api_clean_civitai_info)
|
||||
app.router.add_get('/anomalous/compatible_models', api_compatible_models)
|
||||
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
|
||||
app.router.add_post('/anomalous/save_notebook', api_save_notebook)
|
||||
app.router.add_post('/anomalous/delete_notebook', api_delete_notebook)
|
||||
app.router.add_post('/anomalous/translate', api_translate)
|
||||
app.router.add_get('/anomalous/base_models', api_base_models)
|
||||
app.router.add_get('/anomalous/gallery_images', api_get_gallery_images)
|
||||
app.router.add_post('/anomalous/delete_gallery_image', api_delete_gallery_image)
|
||||
app.router.add_get('/anomalous/resolve_hash', api_resolve_hash)
|
||||
app.router.add_get('/anomalous/all_hashes', api_get_all_hashes)
|
||||
app.router.add_post('/anomalous/scan_all', api_scan_all)
|
||||
app.router.add_get('/anomalous/global_scan_status', api_global_scan_status)
|
||||
@@ -0,0 +1,100 @@
|
||||
from . import (
|
||||
folder_types, gallery_routes, materials, media_routes, model_catalog,
|
||||
model_media, model_metadata, model_resolution, recipe_packages, recipes,
|
||||
translation_routes, version_manager,
|
||||
)
|
||||
from .scanner import *
|
||||
from .config import *
|
||||
from .notebooks import *
|
||||
from .parameters import *
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@web.middleware
|
||||
async def no_cache_extension_middleware(request, handler):
|
||||
response = await handler(request)
|
||||
if request.path.startswith('/extensions/Anomalous_Model_Browser'):
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
def setup_routes(app):
|
||||
if hasattr(app, 'middlewares') and no_cache_extension_middleware not in app.middlewares:
|
||||
app.middlewares.append(no_cache_extension_middleware)
|
||||
app.router.add_get('/anomalous/folders', model_catalog.api_get_folders)
|
||||
app.router.add_get('/anomalous/all_folder_types', folder_types.api_get_all_folder_types)
|
||||
app.router.add_get('/anomalous/models', model_catalog.api_get_models)
|
||||
app.router.add_get('/anomalous/all_scan_models', model_catalog.api_get_all_scan_models)
|
||||
app.router.add_get('/anomalous/batch_select', model_catalog.api_batch_select)
|
||||
app.router.add_get('/anomalous/image', media_routes.api_serve_image)
|
||||
app.router.add_post('/anomalous/scan', api_scan_folder)
|
||||
app.router.add_get('/anomalous/scan_status', api_scan_status)
|
||||
app.router.add_get('/anomalous/find_model', model_catalog.api_find_model)
|
||||
app.router.add_get('/anomalous/config', api_get_config)
|
||||
app.router.add_post('/anomalous/save_config', api_save_config)
|
||||
app.router.add_post('/anomalous/delete_model', model_metadata.api_delete_model)
|
||||
app.router.add_post('/anomalous/clean_civitai_info', api_clean_civitai_info)
|
||||
app.router.add_get('/anomalous/compatible_models', model_catalog.api_compatible_models)
|
||||
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
|
||||
app.router.add_post('/anomalous/save_notebook', api_save_notebook)
|
||||
app.router.add_post('/anomalous/delete_notebook', api_delete_notebook)
|
||||
|
||||
# Parameter Routes
|
||||
app.router.add_get('/anomalous/parameters', api_get_parameters)
|
||||
app.router.add_get('/anomalous/parameters/by_node_type', api_get_parameters_by_type)
|
||||
app.router.add_post('/anomalous/save_parameter', api_save_parameter)
|
||||
app.router.add_post('/anomalous/rename_parameter', api_rename_parameter)
|
||||
app.router.add_post('/anomalous/delete_parameter', api_delete_parameter)
|
||||
app.router.add_get('/anomalous/parameter_gallery', api_get_parameter_gallery)
|
||||
|
||||
# Recipe Routes
|
||||
app.router.add_get('/anomalous/recipes', recipes.api_get_recipes)
|
||||
app.router.add_get('/anomalous/recipe_full', recipes.api_get_recipe_full)
|
||||
app.router.add_get('/anomalous/recipe_asset', recipes.api_get_recipe_asset)
|
||||
app.router.add_get('/anomalous/recipe_gallery', recipes.api_get_recipe_gallery)
|
||||
app.router.add_get('/anomalous/recipe_parameter_gallery', recipes.api_get_recipe_parameter_gallery)
|
||||
app.router.add_get('/anomalous/recipe_gallery_compare', recipes.api_get_recipe_gallery_compare)
|
||||
app.router.add_post('/anomalous/save_recipe', recipes.api_save_recipe)
|
||||
app.router.add_post('/anomalous/update_recipe', recipes.api_update_recipe)
|
||||
app.router.add_post('/anomalous/set_recipe_gallery_cover', recipes.api_set_recipe_gallery_cover)
|
||||
app.router.add_post('/anomalous/delete_recipe', recipes.api_delete_recipe)
|
||||
app.router.add_get('/anomalous/recipe_history', recipes.api_get_recipe_history)
|
||||
app.router.add_get('/anomalous/recipe_version', recipes.api_get_recipe_version)
|
||||
app.router.add_post('/anomalous/restore_recipe_version', recipes.api_restore_recipe_version)
|
||||
app.router.add_post('/anomalous/refresh_recipe_identity', recipes.api_refresh_recipe_identity)
|
||||
app.router.add_post('/anomalous/export_recipe_package', recipe_packages.api_export_recipe_package)
|
||||
app.router.add_post('/anomalous/import_recipe_package_inspect', recipe_packages.api_import_recipe_package_inspect)
|
||||
app.router.add_post('/anomalous/import_recipe_package_commit', recipe_packages.api_import_recipe_package_commit)
|
||||
|
||||
# Curated Material Library Routes
|
||||
app.router.add_get('/anomalous/materials', materials.api_get_materials)
|
||||
app.router.add_get('/anomalous/material_full', materials.api_get_material_full)
|
||||
app.router.add_get('/anomalous/material_asset', materials.api_get_material_asset)
|
||||
app.router.add_get('/anomalous/materials/by_node_type', materials.api_get_materials_by_node_type)
|
||||
app.router.add_post('/anomalous/inspect_image_material', materials.api_inspect_image_material)
|
||||
app.router.add_post('/anomalous/save_image_material', materials.api_save_image_material)
|
||||
app.router.add_post('/anomalous/save_parameter_material', materials.api_save_parameter_material)
|
||||
app.router.add_post('/anomalous/save_prompt_note_material', materials.api_save_prompt_note_material)
|
||||
app.router.add_post('/anomalous/save_prompt_plan', materials.api_save_prompt_plan)
|
||||
app.router.add_post('/anomalous/delete_material', materials.api_delete_material)
|
||||
app.router.add_post('/anomalous/update_material', materials.api_update_material)
|
||||
|
||||
app.router.add_post('/anomalous/translate', translation_routes.api_translate)
|
||||
app.router.add_get('/anomalous/base_models', model_catalog.api_base_models)
|
||||
app.router.add_get('/anomalous/gallery_images', gallery_routes.api_get_gallery_images)
|
||||
app.router.add_post('/anomalous/delete_gallery_image', gallery_routes.api_delete_gallery_image)
|
||||
app.router.add_get('/anomalous/resolve_hash', model_resolution.api_resolve_hash)
|
||||
app.router.add_post('/anomalous/resolve_hash_batch', model_resolution.api_resolve_hash_batch)
|
||||
app.router.add_get('/anomalous/all_hashes', model_resolution.api_get_all_hashes)
|
||||
app.router.add_post('/anomalous/scan_all', api_scan_all)
|
||||
app.router.add_get('/anomalous/global_scan_status', api_global_scan_status)
|
||||
app.router.add_get('/anomalous/scan_missing_models_status', api_scan_missing_models_status)
|
||||
app.router.add_post('/anomalous/clear_cache', media_routes.api_clear_cache)
|
||||
app.router.add_post('/anomalous/update_metadata', model_metadata.api_update_metadata)
|
||||
app.router.add_post('/anomalous/set_custom_cover', model_media.api_set_custom_cover)
|
||||
app.router.add_post('/anomalous/upload_custom_cover', model_media.api_upload_custom_cover)
|
||||
app.router.add_get('/anomalous/model_images', media_routes.api_get_model_images)
|
||||
app.router.add_post('/anomalous/resolve_paths_to_previews', model_catalog.api_resolve_paths_to_previews)
|
||||
app.router.add_post('/anomalous/scan_missing_models', api_scan_missing_models)
|
||||
version_manager.register_routes(app)
|
||||
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
|
||||
async def api_get_config(request):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
has_key = False
|
||||
folder_types = []
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
has_key = bool(cfg.get("CIVITAI_API_KEY", "").strip())
|
||||
folder_types = cfg.get("folder_types_config", [])
|
||||
except:
|
||||
pass
|
||||
if not has_key:
|
||||
legacy_config_path = os.path.join(os.path.dirname(script_dir), "config.json")
|
||||
if os.path.exists(legacy_config_path):
|
||||
try:
|
||||
with open(legacy_config_path, 'r', encoding='utf-8') as f:
|
||||
legacy_cfg = json.load(f)
|
||||
has_key = bool(legacy_cfg.get("CIVITAI_API_KEY", "").strip())
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({"has_api_key": has_key, "folder_types_config": folder_types})
|
||||
|
||||
async def api_save_config(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
api_key = data.get("api_key")
|
||||
folder_types_config = data.get("folder_types_config")
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
cfg = {}
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
if api_key is not None and not isinstance(api_key, str):
|
||||
return web.json_response({"status": "error", "message": "Invalid API key"}, status=400)
|
||||
if folder_types_config is not None and not isinstance(folder_types_config, list):
|
||||
return web.json_response({"status": "error", "message": "Invalid folder configuration"}, status=400)
|
||||
if "physical_folders_config" in data and not isinstance(data.get("physical_folders_config"), list):
|
||||
return web.json_response({"status": "error", "message": "Invalid physical folder configuration"}, status=400)
|
||||
if "folder_view_mode" in data and data.get("folder_view_mode") not in {"abstract", "physical"}:
|
||||
return web.json_response({"status": "error", "message": "Invalid folder view mode"}, status=400)
|
||||
|
||||
if api_key is not None:
|
||||
cfg["CIVITAI_API_KEY"] = api_key.strip()
|
||||
|
||||
if folder_types_config is not None:
|
||||
cfg["folder_types_config"] = folder_types_config
|
||||
|
||||
if "physical_folders_config" in data:
|
||||
cfg["physical_folders_config"] = data.get("physical_folders_config")
|
||||
|
||||
if "folder_view_mode" in data:
|
||||
cfg["folder_view_mode"] = data.get("folder_view_mode")
|
||||
|
||||
temp_path = config_path + ".tmp"
|
||||
with open(temp_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, indent=4)
|
||||
os.replace(temp_path, config_path)
|
||||
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Configured model-folder visibility and scan-scope helpers."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
|
||||
DEFAULT_PHYSICAL_FOLDER_NAMES = {
|
||||
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae'
|
||||
}
|
||||
|
||||
|
||||
def get_folder_view_mode():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
return cfg.get("folder_view_mode", "abstract")
|
||||
except:
|
||||
pass
|
||||
return "abstract"
|
||||
|
||||
|
||||
def get_all_physical_basenames():
|
||||
basenames = set()
|
||||
for t in folder_paths.folder_names_and_paths.keys():
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
if not paths: continue
|
||||
for p in paths:
|
||||
bn = os.path.basename(os.path.normpath(p))
|
||||
if bn: basenames.add(bn)
|
||||
except:
|
||||
pass
|
||||
return list(basenames)
|
||||
|
||||
|
||||
def get_active_physical_basenames():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
all_bns = get_all_physical_basenames()
|
||||
active = []
|
||||
configured = set()
|
||||
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
pfc = cfg.get("physical_folders_config")
|
||||
if pfc and isinstance(pfc, list):
|
||||
for item in pfc:
|
||||
bn = item.get("type")
|
||||
if bn in all_bns:
|
||||
configured.add(bn)
|
||||
if item.get("visible", True):
|
||||
active.append(bn)
|
||||
|
||||
for bn in all_bns:
|
||||
if bn not in configured and bn in DEFAULT_PHYSICAL_FOLDER_NAMES:
|
||||
active.append(bn)
|
||||
return active
|
||||
except:
|
||||
pass
|
||||
|
||||
return [bn for bn in all_bns if bn in DEFAULT_PHYSICAL_FOLDER_NAMES]
|
||||
|
||||
|
||||
def get_active_model_roots():
|
||||
"""Return the folder-manager-visible roots with stable model locators.
|
||||
|
||||
This is intentionally separate from the scanner scope. The source hub only
|
||||
needs directory discovery and must preserve ``type``/``path_idx`` so later
|
||||
metadata writes resolve back to the exact ComfyUI root.
|
||||
"""
|
||||
mode = get_folder_view_mode()
|
||||
active_types = set(get_active_folder_types()) if mode != "physical" else None
|
||||
active_basenames = (
|
||||
set(get_active_physical_basenames()) if mode == "physical" else None
|
||||
)
|
||||
roots = []
|
||||
seen_realpaths = set()
|
||||
|
||||
for folder_type in folder_paths.folder_names_and_paths.keys():
|
||||
if active_types is not None and folder_type not in active_types:
|
||||
continue
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type) or []
|
||||
except Exception:
|
||||
continue
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if active_basenames is not None:
|
||||
basename = os.path.basename(os.path.normpath(base_dir))
|
||||
if basename not in active_basenames:
|
||||
continue
|
||||
if not os.path.isdir(base_dir):
|
||||
continue
|
||||
real_dir = os.path.realpath(base_dir)
|
||||
real_key = os.path.normcase(real_dir)
|
||||
if real_key in seen_realpaths:
|
||||
continue
|
||||
seen_realpaths.add(real_key)
|
||||
roots.append({
|
||||
"type": folder_type,
|
||||
"path_idx": path_idx,
|
||||
"base_dir": real_dir,
|
||||
})
|
||||
return roots
|
||||
|
||||
|
||||
def get_active_scan_paths():
|
||||
mode = get_folder_view_mode()
|
||||
paths = set()
|
||||
|
||||
if mode == "physical":
|
||||
active_bns = set(get_active_physical_basenames())
|
||||
for t in folder_paths.folder_names_and_paths.keys():
|
||||
try:
|
||||
ps = folder_paths.get_folder_paths(t)
|
||||
if not ps: continue
|
||||
for p in ps:
|
||||
if not os.path.exists(p): continue
|
||||
bn = os.path.basename(os.path.normpath(p))
|
||||
if bn in active_bns:
|
||||
paths.add(os.path.realpath(p))
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
active_types = get_active_folder_types()
|
||||
for t in active_types:
|
||||
try:
|
||||
ps = folder_paths.get_folder_paths(t)
|
||||
if not ps: continue
|
||||
for p in ps:
|
||||
if not os.path.exists(p): continue
|
||||
paths.add(os.path.realpath(p))
|
||||
except:
|
||||
pass
|
||||
|
||||
return list(paths)
|
||||
|
||||
|
||||
def get_active_folder_types():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
all_types = list(folder_paths.folder_names_and_paths.keys())
|
||||
default_types = ['checkpoints', 'loras', 'diffusion_models', 'unet', 'controlnet', 'vae']
|
||||
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
ftc = cfg.get("folder_types_config")
|
||||
if ftc and isinstance(ftc, list):
|
||||
active = []
|
||||
configured_types = set()
|
||||
for item in ftc:
|
||||
t = item.get("type")
|
||||
if t in all_types:
|
||||
configured_types.add(t)
|
||||
if item.get("visible", True):
|
||||
active.append(t)
|
||||
# For any newly registered types not in config, do not show them by default
|
||||
# unless they are in default_types
|
||||
for t in all_types:
|
||||
if t not in configured_types and t in default_types:
|
||||
active.append(t)
|
||||
return active
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback if no config exists
|
||||
active = [t for t in default_types if t in all_types]
|
||||
return active
|
||||
|
||||
|
||||
async def api_get_all_folder_types(request):
|
||||
mode = get_folder_view_mode()
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
result = []
|
||||
|
||||
if mode == "physical":
|
||||
all_bns = get_all_physical_basenames()
|
||||
configured = set()
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
pfc = cfg.get("physical_folders_config")
|
||||
if pfc and isinstance(pfc, list):
|
||||
for item in pfc:
|
||||
bn = item.get("type")
|
||||
if bn in all_bns:
|
||||
configured.add(bn)
|
||||
result.append({
|
||||
"type": bn,
|
||||
"visible": item.get("visible", True)
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
for bn in all_bns:
|
||||
if bn not in configured:
|
||||
result.append({
|
||||
"type": bn,
|
||||
"visible": bn in DEFAULT_PHYSICAL_FOLDER_NAMES
|
||||
})
|
||||
else:
|
||||
# Abstract mode
|
||||
all_types = list(folder_paths.folder_names_and_paths.keys())
|
||||
default_types = ['checkpoints', 'loras', 'diffusion_models', 'controlnet', 'vae']
|
||||
configured_types = set()
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
ftc = cfg.get("folder_types_config")
|
||||
if ftc and isinstance(ftc, list):
|
||||
for item in ftc:
|
||||
t = item.get("type")
|
||||
if t in all_types:
|
||||
configured_types.add(t)
|
||||
result.append({
|
||||
"type": t,
|
||||
"visible": item.get("visible", True)
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
for t in all_types:
|
||||
if t not in configured_types:
|
||||
result.append({
|
||||
"type": t,
|
||||
"visible": t in default_types
|
||||
})
|
||||
|
||||
return web.json_response({"folder_types": result, "folder_view_mode": mode})
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Generated-output gallery indexing and mutation routes."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .image_search import filter_images
|
||||
from .path_utils import require_filename, resolve_within
|
||||
|
||||
|
||||
GALLERY_SNAPSHOT_SECONDS = 10
|
||||
MAX_CACHED_GALLERY_IMAGES = 50_000
|
||||
_gallery_snapshot_lock = threading.Lock()
|
||||
_gallery_snapshot = {"root": None, "expires": 0, "images": []}
|
||||
|
||||
|
||||
def _collect_gallery_images(output_dir):
|
||||
images = []
|
||||
for root, _, files in os.walk(output_dir):
|
||||
for filename in files:
|
||||
if os.path.splitext(filename)[1].lower() not in {'.png', '.jpg', '.jpeg', '.webp', '.gif'}:
|
||||
continue
|
||||
try:
|
||||
mtime = os.path.getmtime(os.path.join(root, filename))
|
||||
except OSError:
|
||||
continue
|
||||
subfolder = os.path.relpath(root, output_dir)
|
||||
images.append({
|
||||
"filename": filename,
|
||||
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, '/'),
|
||||
"type": "output", "mtime": mtime,
|
||||
})
|
||||
images.sort(key=lambda item: (item['mtime'], item['subfolder'], item['filename']), reverse=True)
|
||||
return images
|
||||
|
||||
|
||||
def _gallery_images(output_dir, refresh=False):
|
||||
root = os.path.realpath(output_dir)
|
||||
with _gallery_snapshot_lock:
|
||||
if not refresh and _gallery_snapshot['root'] == root and time.monotonic() < _gallery_snapshot['expires']:
|
||||
return _gallery_snapshot['images']
|
||||
images = _collect_gallery_images(root)
|
||||
_gallery_snapshot.update(root=root, expires=time.monotonic() + GALLERY_SNAPSHOT_SECONDS,
|
||||
images=images if len(images) <= MAX_CACHED_GALLERY_IMAGES else [])
|
||||
if len(images) > MAX_CACHED_GALLERY_IMAGES:
|
||||
_gallery_snapshot['expires'] = 0
|
||||
return images
|
||||
|
||||
|
||||
def _invalidate_gallery_snapshot():
|
||||
with _gallery_snapshot_lock:
|
||||
_gallery_snapshot.update(root=None, expires=0, images=[])
|
||||
|
||||
|
||||
def _gallery_listing(output_dir, refresh, query):
|
||||
images = _gallery_images(output_dir, refresh)
|
||||
return filter_images(os.path.realpath(output_dir), images, query) if query else images
|
||||
|
||||
|
||||
async def api_get_gallery_images(request):
|
||||
"""GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q) - Output images, optionally searched by embedded generation info."""
|
||||
try:
|
||||
page = max(1, int(request.query.get('page', 1)))
|
||||
limit = min(200, max(1, int(request.query.get('limit', 50))))
|
||||
# Repeated `term` params keep phrases whole; `q` is split on whitespace.
|
||||
terms = [term for term in request.query.getall('term', []) if term.strip()]
|
||||
query = terms or request.query.get('q', '').strip()
|
||||
images = await asyncio.to_thread(_gallery_listing, folder_paths.get_output_directory(), request.query.get('refresh') == '1', query)
|
||||
total = len(images)
|
||||
start = (page - 1) * limit
|
||||
return web.json_response({"images": images[start:start + limit], "total": total,
|
||||
"page": page, "pages": (total + limit - 1) // limit})
|
||||
except (TypeError, ValueError):
|
||||
return web.json_response({"error": "Invalid pagination"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"error": "Could not list output images"}, status=500)
|
||||
|
||||
|
||||
async def api_delete_gallery_image(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
data = None
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
|
||||
try:
|
||||
filename = data.get("filename")
|
||||
subfolder = data.get("subfolder", "")
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
target_dir = resolve_within(output_dir, subfolder)
|
||||
file_path = resolve_within(target_dir, filename)
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
_invalidate_gallery_snapshot()
|
||||
return web.json_response({"status": "success"})
|
||||
else:
|
||||
return web.json_response({"status": "error", "message": "File not found"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Search output images by the generation info embedded in them.
|
||||
|
||||
Reads only the PNG text chunks before the pixel data (ComfyUI `prompt` /
|
||||
`workflow`, A1111/Forge `parameters`), caches one small record per file keyed by
|
||||
its mtime, and matches every query term against it. Terms that look like a
|
||||
hash also match model SHA256 values recorded in the workflow and, through the
|
||||
scanned model index, the local model files that carry that hash.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
MAX_CHUNK_BYTES = 32 * 1024 * 1024
|
||||
MAX_CACHED_RECORDS = 60_000
|
||||
MODEL_INDEX_SECONDS = 120
|
||||
HASH_TERM = re.compile(r"^[0-9a-f]{8,64}$", re.IGNORECASE)
|
||||
MODEL_FILE = re.compile(r"\.(safetensors|ckpt|pt|pth|bin|sft|gguf)$", re.IGNORECASE)
|
||||
NUMERIC_KEYS = {"seed", "noise_seed", "steps", "cfg", "denoise", "width", "height"}
|
||||
|
||||
_record_cache = {}
|
||||
_cache_lock = threading.Lock()
|
||||
_model_index = {"expires": 0, "entries": {}}
|
||||
_model_index_lock = threading.Lock()
|
||||
|
||||
|
||||
def _decode_text(raw):
|
||||
"""tEXt is Latin-1 by spec, but ComfyUI/PIL may store UTF-8 bytes in it."""
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return raw.decode("latin-1", "replace")
|
||||
|
||||
|
||||
def read_png_text(path):
|
||||
"""Text chunks that precede IDAT, as {keyword: text}. Pixel data is never read."""
|
||||
chunks = {}
|
||||
with open(path, "rb") as handle:
|
||||
if handle.read(8) != PNG_SIGNATURE:
|
||||
return chunks
|
||||
while True:
|
||||
header = handle.read(8)
|
||||
if len(header) < 8:
|
||||
break
|
||||
length, kind = struct.unpack(">I4s", header)
|
||||
if kind in (b"IDAT", b"IEND") or length > MAX_CHUNK_BYTES:
|
||||
break
|
||||
data = handle.read(length)
|
||||
handle.seek(4, os.SEEK_CUR)
|
||||
try:
|
||||
if kind == b"tEXt":
|
||||
key, _, value = data.partition(b"\x00")
|
||||
chunks[key.decode("latin-1")] = _decode_text(value)
|
||||
elif kind == b"zTXt":
|
||||
key, _, rest = data.partition(b"\x00")
|
||||
chunks[key.decode("latin-1")] = _decode_text(zlib.decompress(rest[1:]))
|
||||
elif kind == b"iTXt":
|
||||
key, _, rest = data.partition(b"\x00")
|
||||
compressed, rest = rest[0], rest[2:]
|
||||
_, _, rest = rest.partition(b"\x00") # language tag
|
||||
_, _, text = rest.partition(b"\x00") # translated keyword
|
||||
chunks[key.decode("latin-1")] = (zlib.decompress(text) if compressed else text).decode("utf-8", "replace")
|
||||
except (zlib.error, IndexError, ValueError):
|
||||
continue
|
||||
return chunks
|
||||
|
||||
|
||||
def _normalise_model_ref(value):
|
||||
ref = str(value).replace("\\", "/").strip().lower()
|
||||
return {ref, ref.rsplit("/", 1)[-1]}
|
||||
|
||||
|
||||
def build_record(path):
|
||||
"""Searchable text, recorded model hashes and referenced model files for one image."""
|
||||
name = os.path.basename(path)
|
||||
texts = [name]
|
||||
hashes = set()
|
||||
models = set()
|
||||
if path.lower().endswith(".png"):
|
||||
try:
|
||||
chunks = read_png_text(path)
|
||||
except OSError:
|
||||
chunks = {}
|
||||
prompt = _load_json(chunks.get("prompt"))
|
||||
if isinstance(prompt, dict):
|
||||
for node in prompt.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
texts.append(str(node.get("class_type", "")))
|
||||
for key, value in (node.get("inputs") or {}).items():
|
||||
if isinstance(value, str):
|
||||
texts.append(value)
|
||||
if MODEL_FILE.search(value):
|
||||
models |= _normalise_model_ref(value)
|
||||
elif isinstance(value, (int, float)) and not isinstance(value, bool) and key in NUMERIC_KEYS:
|
||||
texts.append(f"{key}:{value} {value}")
|
||||
workflow = _load_json(chunks.get("workflow"))
|
||||
recorded = (workflow or {}).get("extra", {}).get("anomalous_hashes") if isinstance(workflow, dict) else None
|
||||
if isinstance(recorded, dict):
|
||||
for key, entry in recorded.items():
|
||||
sha = entry.get("hash") if isinstance(entry, dict) else entry
|
||||
if isinstance(sha, str) and sha:
|
||||
hashes.add(sha.upper())
|
||||
model_name = str(key).split("_", 1)[-1]
|
||||
if MODEL_FILE.search(model_name):
|
||||
models |= _normalise_model_ref(model_name)
|
||||
if chunks.get("parameters"):
|
||||
texts.append(chunks["parameters"])
|
||||
return {"text": "\n".join(texts).lower(), "hashes": hashes, "models": models}
|
||||
|
||||
|
||||
def _load_json(raw):
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def cached_record(path, mtime=None):
|
||||
"""Record for one image; `mtime` from the gallery listing avoids a stat per file."""
|
||||
if mtime is None:
|
||||
try:
|
||||
mtime = os.stat(path).st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
key = mtime
|
||||
with _cache_lock:
|
||||
cached = _record_cache.get(path)
|
||||
if cached and cached[0] == key:
|
||||
return cached[1]
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
record = build_record(path)
|
||||
with _cache_lock:
|
||||
if len(_record_cache) >= MAX_CACHED_RECORDS:
|
||||
_record_cache.clear()
|
||||
_record_cache[path] = (key, record)
|
||||
return record
|
||||
|
||||
|
||||
def _local_models_for_hash(term):
|
||||
"""Model file refs whose scanned SHA256 starts with `term` (cached briefly)."""
|
||||
# Imported lazily: api.utils re-exports gallery_routes, which imports this module.
|
||||
from .model_resolution import collect_model_hash_index
|
||||
|
||||
with _model_index_lock:
|
||||
if time.monotonic() >= _model_index["expires"]:
|
||||
_model_index["entries"] = collect_model_hash_index()
|
||||
_model_index["expires"] = time.monotonic() + MODEL_INDEX_SECONDS
|
||||
entries = _model_index["entries"]
|
||||
wanted = term.upper()
|
||||
refs = set()
|
||||
for key, value in entries.items():
|
||||
sha = str((value or {}).get("hash") or "").upper()
|
||||
if sha and sha.startswith(wanted):
|
||||
refs |= _normalise_model_ref(key)
|
||||
return refs
|
||||
|
||||
|
||||
def parse_query(query):
|
||||
"""Search terms: a list keeps each entry as one phrase; a string splits on whitespace."""
|
||||
raw = query if isinstance(query, (list, tuple)) else str(query or "").split()
|
||||
terms = []
|
||||
for term in raw:
|
||||
term = " ".join(str(term).lower().split())
|
||||
if term and term not in terms:
|
||||
terms.append(term)
|
||||
return terms
|
||||
|
||||
|
||||
def filter_images(output_dir, images, query):
|
||||
"""Keep gallery entries whose record matches every term (phrases may contain spaces)."""
|
||||
terms = parse_query(query)
|
||||
if not terms:
|
||||
return images
|
||||
hash_refs = {term: _local_models_for_hash(term) for term in terms if HASH_TERM.match(term)}
|
||||
matched = []
|
||||
for image in images:
|
||||
record = cached_record(os.path.join(output_dir, image.get("subfolder", ""), image["filename"]), image.get("mtime"))
|
||||
if record is None:
|
||||
continue
|
||||
if all(_term_matches(term, record, hash_refs) for term in terms):
|
||||
matched.append(image)
|
||||
return matched
|
||||
|
||||
|
||||
def _term_matches(term, record, hash_refs):
|
||||
if term in record["text"]:
|
||||
return True
|
||||
if term in hash_refs:
|
||||
upper = term.upper()
|
||||
if any(sha.startswith(upper) for sha in record["hashes"]):
|
||||
return True
|
||||
return bool(hash_refs[term] & record["models"])
|
||||
return False
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Image inspection and asset storage for curated materials."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from .recipe_images import _embedded_workflow_payload, _output_source_path, _recipe_cover_webp_bytes
|
||||
from .recipe_schema import _build_model_references, _normalise_source_image
|
||||
from .workflow_schema import _validate_workflow
|
||||
from .material_schema import _node_blocks, _suggested_name
|
||||
from .utils import require_filename, resolve_within
|
||||
|
||||
|
||||
MAX_SOURCE_IMAGE_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def _material_assets_dir(materials_dir, filename, create=False):
|
||||
stem = os.path.splitext(require_filename(filename))[0]
|
||||
assets_dir = resolve_within(materials_dir, ".assets", stem)
|
||||
if create:
|
||||
os.makedirs(assets_dir, exist_ok=True)
|
||||
return assets_dir
|
||||
|
||||
|
||||
def _inspect_source_image(source_image):
|
||||
source = _normalise_source_image(source_image)
|
||||
if source is None:
|
||||
raise ValueError("Missing source image")
|
||||
if not source["filename"].lower().endswith(".png"):
|
||||
raise ValueError("Only PNG output images can contain reusable workflow metadata")
|
||||
source_path = _output_source_path(source)
|
||||
payload = _embedded_workflow_payload(source_path)
|
||||
workflow = payload.get("workflow") if isinstance(payload, dict) else None
|
||||
if not isinstance(workflow, dict):
|
||||
raise ValueError("Image has no reusable UI workflow")
|
||||
_validate_workflow(workflow)
|
||||
blocks = _node_blocks(workflow, include_values=False)
|
||||
references = _build_model_references({"workflow": workflow, "params": {}}, verify_identities=False)
|
||||
suggested_name = _suggested_name(workflow, source_path, references)
|
||||
return source, source_path, workflow, blocks, references, suggested_name
|
||||
|
||||
|
||||
def _store_assets(materials_dir, filename, source_path):
|
||||
source_size = os.path.getsize(source_path)
|
||||
if source_size > MAX_SOURCE_IMAGE_BYTES:
|
||||
raise ValueError("Source image is too large")
|
||||
digest = hashlib.sha256()
|
||||
with open(source_path, "rb") as source_file:
|
||||
for block in iter(lambda: source_file.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
assets_dir = _material_assets_dir(materials_dir, filename, create=True)
|
||||
source_asset = f"source-{digest.hexdigest()}.png"
|
||||
source_target = resolve_within(assets_dir, source_asset)
|
||||
if not os.path.exists(source_target):
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".source-", suffix=".tmp", dir=assets_dir)
|
||||
os.close(fd)
|
||||
try:
|
||||
shutil.copyfile(source_path, temp_path)
|
||||
os.replace(temp_path, source_target)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
preview = _recipe_cover_webp_bytes(source_path)
|
||||
preview_asset = None
|
||||
if preview:
|
||||
data, width, height = preview
|
||||
preview_asset = f"preview-{hashlib.sha256(data).hexdigest()}.webp"
|
||||
preview_target = resolve_within(assets_dir, preview_asset)
|
||||
if not os.path.exists(preview_target):
|
||||
with open(preview_target, "wb") as preview_file:
|
||||
preview_file.write(data)
|
||||
else:
|
||||
width = height = None
|
||||
return {
|
||||
"source_asset_id": source_asset,
|
||||
"preview_asset_id": preview_asset,
|
||||
"preview_width": width,
|
||||
"preview_height": height,
|
||||
"source_sha256": digest.hexdigest(),
|
||||
"source_size": source_size,
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
"""Validation and record-shaping helpers for curated materials."""
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from .notebooks import MAX_NOTEBOOK_BYTES
|
||||
from .recipe_schema import _build_model_references
|
||||
from .workflow_schema import _parameter_signature, _volatile_widget_indexes, _workflow_fingerprint
|
||||
|
||||
|
||||
MAX_MATERIAL_NAME_LENGTH = 120
|
||||
MODEL_PRIORITY = {"checkpoint": 0, "unet": 1, "lora": 2}
|
||||
_PROMPT_NODE_TYPES = {"cliptextencode"}
|
||||
_PROMPT_CONSUMERS = {
|
||||
"ksampler": {"positive": "positive", "negative": "negative"},
|
||||
"ksampleradvanced": {"positive": "positive", "negative": "negative"},
|
||||
"cfgguider": {"positive": "positive", "negative": "negative"},
|
||||
"basicguider": {"conditioning": "positive"},
|
||||
"dualcfgguider": {"cond1": "positive", "cond2": "positive", "negative": "negative"},
|
||||
}
|
||||
_PROMPT_PASSTHROUGH = {
|
||||
"conditioningaverage", "conditioningcombine", "conditioningconcat",
|
||||
"conditioningmultiply", "conditioningsetarea", "conditioningsetareapercentage",
|
||||
"conditioningsetareastrength", "conditioningsetmask", "conditioningsettimesteprange",
|
||||
"conditioningzeroout",
|
||||
}
|
||||
_PROMPT_ROLES = {"positive", "negative", "both", "ignored", "unknown"}
|
||||
_MAX_PROMPT_UPSTREAM = 96
|
||||
|
||||
|
||||
def _node_blocks(workflow, include_values=True, selected_ids=None):
|
||||
blocks = []
|
||||
occurrences = {}
|
||||
for node in workflow.get("nodes", []):
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_type = str(node.get("type") or "").strip()
|
||||
if not node_type:
|
||||
continue
|
||||
occurrences[node_type] = occurrences.get(node_type, 0) + 1
|
||||
if selected_ids is not None and str(node.get("id")) not in selected_ids:
|
||||
continue
|
||||
block = {
|
||||
"node_id": node.get("id"),
|
||||
"type": node_type,
|
||||
"title": str(node.get("title") or node_type),
|
||||
"occurrence": occurrences[node_type],
|
||||
"widget_count": len(node.get("widgets_values") or []),
|
||||
"volatile_widget_indexes": list(_volatile_widget_indexes(node)),
|
||||
}
|
||||
if include_values:
|
||||
block["widgets_values"] = copy.deepcopy(node.get("widgets_values") or [])
|
||||
block["properties"] = copy.deepcopy(node.get("properties") or {})
|
||||
if node.get("mode") is not None:
|
||||
block["mode"] = node.get("mode")
|
||||
blocks.append(block)
|
||||
return blocks
|
||||
|
||||
|
||||
def _normalise_selected_node_ids(workflow, raw_node_ids):
|
||||
"""Validate an optional node selection while preserving workflow ID types."""
|
||||
if raw_node_ids is None:
|
||||
return None
|
||||
if not isinstance(raw_node_ids, list) or not raw_node_ids:
|
||||
raise ValueError("Selected material nodes are required")
|
||||
|
||||
workflow_nodes = [node for node in workflow.get("nodes", []) if isinstance(node, dict)]
|
||||
existing = {str(node.get("id")): node.get("id") for node in workflow_nodes if node.get("id") is not None}
|
||||
selected = []
|
||||
seen = set()
|
||||
for raw_node_id in raw_node_ids:
|
||||
key = str(raw_node_id)
|
||||
if key not in existing:
|
||||
raise ValueError("Selected material node does not exist")
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
selected.append(existing[key])
|
||||
if not selected:
|
||||
raise ValueError("Selected material nodes are required")
|
||||
return selected
|
||||
|
||||
|
||||
def _material_node_ids(material):
|
||||
selection = material.get("selection") if isinstance(material, dict) else None
|
||||
if not isinstance(selection, dict) or selection.get("scope") != "nodes":
|
||||
return None
|
||||
node_ids = selection.get("node_ids")
|
||||
if not isinstance(node_ids, list):
|
||||
return None
|
||||
return {str(node_id) for node_id in node_ids}
|
||||
|
||||
|
||||
def _material_node_blocks(material, include_values=True):
|
||||
if material.get("kind") in ("prompt_note_bundle", "prompt_text", "prompt_plan"):
|
||||
return []
|
||||
return _node_blocks(material.get("workflow") or {}, include_values=include_values,
|
||||
selected_ids=_material_node_ids(material))
|
||||
|
||||
|
||||
def _prompt_excerpt(workflow):
|
||||
for node in workflow.get("nodes", []):
|
||||
if not isinstance(node, dict) or "cliptextencode" not in str(node.get("type") or "").lower():
|
||||
continue
|
||||
for value in node.get("widgets_values") or []:
|
||||
if isinstance(value, str) and value.strip():
|
||||
compact = re.sub(r"\s+", " ", value).strip()
|
||||
return compact[:20]
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_workflow_params(workflow):
|
||||
params = {}
|
||||
prompts = []
|
||||
if not isinstance(workflow, dict):
|
||||
return params, prompts
|
||||
|
||||
for node in workflow.get("nodes", []):
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
ntype = str(node.get("type") or "").strip()
|
||||
ntype_lower = ntype.lower()
|
||||
widgets = node.get("widgets_values") or []
|
||||
|
||||
if ntype_lower in ("ksampler", "ksampleradvanced") and isinstance(widgets, (list, tuple)):
|
||||
is_adv = ntype_lower == "ksampleradvanced"
|
||||
offset = 1 if is_adv else 0
|
||||
if len(widgets) > offset and widgets[offset] is not None:
|
||||
params.setdefault("seed", widgets[offset])
|
||||
if len(widgets) > 2 + offset and widgets[2 + offset] is not None:
|
||||
params.setdefault("steps", widgets[2 + offset])
|
||||
if len(widgets) > 3 + offset and widgets[3 + offset] is not None:
|
||||
params.setdefault("cfg", widgets[3 + offset])
|
||||
if len(widgets) > 4 + offset and widgets[4 + offset] is not None:
|
||||
params.setdefault("sampler_name", widgets[4 + offset])
|
||||
if len(widgets) > 5 + offset and widgets[5 + offset] is not None:
|
||||
params.setdefault("scheduler", widgets[5 + offset])
|
||||
if len(widgets) > 6 + offset and widgets[6 + offset] is not None:
|
||||
params.setdefault("denoise", widgets[6 + offset])
|
||||
elif ntype_lower == "emptylatentimage" and isinstance(widgets, (list, tuple)):
|
||||
if len(widgets) >= 2 and widgets[0] and widgets[1]:
|
||||
params.setdefault("resolution", f"{widgets[0]}×{widgets[1]}")
|
||||
|
||||
if "cliptextencode" in ntype_lower and isinstance(widgets, (list, tuple)):
|
||||
for val in widgets:
|
||||
if isinstance(val, str) and val.strip():
|
||||
cleaned = val.strip()
|
||||
if cleaned not in prompts:
|
||||
prompts.append(cleaned)
|
||||
|
||||
return params, prompts
|
||||
|
||||
|
||||
def _norm_node_name(value):
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
|
||||
def _is_prompt_node_type(node_type):
|
||||
name = _norm_node_name(node_type)
|
||||
return name in _PROMPT_NODE_TYPES or "cliptextencode" in name
|
||||
|
||||
|
||||
def _first_prompt_text(node):
|
||||
for value in node.get("widgets_values") or []:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _title_prompt_role(node):
|
||||
title = str(node.get("title") or "").lower()
|
||||
if "neg" in title or "负向" in title or "反向" in title:
|
||||
return "negative"
|
||||
if "pos" in title or "正向" in title:
|
||||
return "positive"
|
||||
return None
|
||||
|
||||
|
||||
def _workflow_link_origin(workflow, link_id):
|
||||
if link_id is None:
|
||||
return None
|
||||
links = workflow.get("links")
|
||||
if isinstance(links, list):
|
||||
for link in links:
|
||||
if isinstance(link, (list, tuple)) and len(link) > 1 and link[0] == link_id:
|
||||
return link[1]
|
||||
if isinstance(link, dict) and link.get("id") == link_id:
|
||||
return link.get("origin_id")
|
||||
elif isinstance(links, dict):
|
||||
link = links.get(link_id)
|
||||
if link is None:
|
||||
link = links.get(str(link_id))
|
||||
if isinstance(link, dict):
|
||||
return link.get("origin_id")
|
||||
if isinstance(link, (list, tuple)) and len(link) > 1:
|
||||
return link[1]
|
||||
return None
|
||||
|
||||
|
||||
def _collect_prompt_nodes(workflow, nodes_by_id, start_id):
|
||||
queue = [start_id]
|
||||
visited = set()
|
||||
found = []
|
||||
while queue and len(visited) < _MAX_PROMPT_UPSTREAM:
|
||||
node_id = queue.pop(0)
|
||||
if node_id is None or node_id in visited:
|
||||
continue
|
||||
visited.add(node_id)
|
||||
node = nodes_by_id.get(str(node_id))
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if _is_prompt_node_type(node.get("type")):
|
||||
if node not in found:
|
||||
found.append(node)
|
||||
continue
|
||||
if _norm_node_name(node.get("type")) not in _PROMPT_PASSTHROUGH:
|
||||
continue
|
||||
for inbound in node.get("inputs") or []:
|
||||
if not isinstance(inbound, dict):
|
||||
continue
|
||||
input_type = _norm_node_name(inbound.get("type"))
|
||||
input_name = _norm_node_name(inbound.get("name"))
|
||||
if input_type != "conditioning" and "conditioning" not in input_name:
|
||||
continue
|
||||
origin_id = _workflow_link_origin(workflow, inbound.get("link"))
|
||||
if origin_id is not None and origin_id not in visited:
|
||||
queue.append(origin_id)
|
||||
return found
|
||||
|
||||
|
||||
def _normalise_prompt_role_overrides(raw):
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
result = {}
|
||||
for key, value in list(raw.items())[:80]:
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
role = value.get("role")
|
||||
if role not in _PROMPT_ROLES:
|
||||
continue
|
||||
node_type = value.get("nodeType")
|
||||
if node_type is not None and (not isinstance(node_type, str) or len(node_type) > 200):
|
||||
continue
|
||||
result[str(key)] = {
|
||||
"role": role,
|
||||
"nodeType": node_type if isinstance(node_type, str) else None,
|
||||
"source": "manual",
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _override_prompt_role(node, overrides):
|
||||
if not isinstance(overrides, dict) or not isinstance(node, dict):
|
||||
return None
|
||||
entry = overrides.get(str(node.get("id")))
|
||||
if not isinstance(entry, dict) or entry.get("role") not in _PROMPT_ROLES:
|
||||
return None
|
||||
expected = entry.get("nodeType")
|
||||
if expected and expected != node.get("type"):
|
||||
return None
|
||||
return entry["role"]
|
||||
|
||||
|
||||
def _prompt_roles_for_workflow(workflow, overrides=None):
|
||||
roles = {}
|
||||
if not isinstance(workflow, dict):
|
||||
return roles
|
||||
nodes = [node for node in workflow.get("nodes") or [] if isinstance(node, dict)]
|
||||
nodes_by_id = {str(node.get("id")): node for node in nodes if node.get("id") is not None}
|
||||
positive_ids = set()
|
||||
negative_ids = set()
|
||||
for node in nodes:
|
||||
mapping = _PROMPT_CONSUMERS.get(_norm_node_name(node.get("type")))
|
||||
if not mapping:
|
||||
continue
|
||||
for input_name, role in mapping.items():
|
||||
for inbound in node.get("inputs") or []:
|
||||
if not isinstance(inbound, dict) or _norm_node_name(inbound.get("name")) != input_name:
|
||||
continue
|
||||
origin_id = _workflow_link_origin(workflow, inbound.get("link"))
|
||||
for prompt_node in _collect_prompt_nodes(workflow, nodes_by_id, origin_id):
|
||||
node_id = str(prompt_node.get("id"))
|
||||
if role == "negative":
|
||||
negative_ids.add(node_id)
|
||||
else:
|
||||
positive_ids.add(node_id)
|
||||
for node in nodes:
|
||||
if not _is_prompt_node_type(node.get("type")):
|
||||
continue
|
||||
node_id = str(node.get("id"))
|
||||
in_positive = node_id in positive_ids
|
||||
in_negative = node_id in negative_ids
|
||||
if in_positive and in_negative:
|
||||
automatic_role, automatic_source = "both", "topology"
|
||||
elif in_positive:
|
||||
automatic_role, automatic_source = "positive", "topology"
|
||||
elif in_negative:
|
||||
automatic_role, automatic_source = "negative", "topology"
|
||||
else:
|
||||
titled = _title_prompt_role(node)
|
||||
automatic_role = titled or "unknown"
|
||||
automatic_source = "title" if titled else "unresolved"
|
||||
override = _override_prompt_role(node, overrides)
|
||||
roles[node_id] = {
|
||||
"role": override or automatic_role,
|
||||
"source": "manual" if override else automatic_source,
|
||||
"automatic_role": automatic_role,
|
||||
}
|
||||
return roles
|
||||
|
||||
|
||||
def _prompt_groups_from_roles(workflow, roles, selected_ids=None):
|
||||
positive = []
|
||||
negative = []
|
||||
if not isinstance(workflow, dict) or not isinstance(roles, dict):
|
||||
return {"positive": positive, "negative": negative}
|
||||
for node in workflow.get("nodes") or []:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_id = str(node.get("id"))
|
||||
if selected_ids is not None and node_id not in selected_ids:
|
||||
continue
|
||||
info = roles.get(node_id)
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
text = _first_prompt_text(node)
|
||||
if not text:
|
||||
continue
|
||||
role = info.get("role")
|
||||
if role in ("positive", "both") and text not in positive:
|
||||
positive.append(text)
|
||||
if role in ("negative", "both") and text not in negative:
|
||||
negative.append(text)
|
||||
return {"positive": positive, "negative": negative}
|
||||
|
||||
|
||||
def _display_model_name(reference):
|
||||
value = str(reference.get("saved_value") or "").replace("\\", "/").split("/")[-1]
|
||||
return re.sub(r"\.(?:safetensors|ckpt|pt|bin|sft)$", "", value, flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def _suggested_name(workflow, source_path, references=None):
|
||||
stem = os.path.splitext(os.path.basename(source_path))[0] if source_path else ""
|
||||
if stem:
|
||||
return f"{stem} · 快照"[:MAX_MATERIAL_NAME_LENGTH]
|
||||
if references is None:
|
||||
references = _build_model_references({"workflow": workflow, "params": {}}, verify_identities=False)
|
||||
references.sort(key=lambda item: MODEL_PRIORITY.get(item.get("category"), 99))
|
||||
model_name = _display_model_name(references[0]) if references else "工作流快照"
|
||||
return f"{model_name} · 快照"[:MAX_MATERIAL_NAME_LENGTH]
|
||||
|
||||
|
||||
def _recipe_link_fingerprint(recipe):
|
||||
if not isinstance(recipe, dict):
|
||||
return ""
|
||||
fingerprint = recipe.get("workflow_fingerprint")
|
||||
workflow_hash = fingerprint.get("value") if isinstance(fingerprint, dict) else ""
|
||||
if not workflow_hash and isinstance(recipe.get("workflow"), dict):
|
||||
workflow_hash = (_workflow_fingerprint(recipe["workflow"]) or {}).get("value") or ""
|
||||
params = recipe.get("params") if isinstance(recipe.get("params"), dict) else {}
|
||||
overrides = params.get("promptRoleOverrides") if isinstance(params.get("promptRoleOverrides"), dict) else {}
|
||||
payload = json.dumps(
|
||||
{"workflow": workflow_hash, "promptRoleOverrides": overrides},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _material_summary(filename, material):
|
||||
image = material.get("image") or {}
|
||||
source = material.get("source") if isinstance(material.get("source"), dict) else {}
|
||||
blocks = _material_node_blocks(material, include_values=False)
|
||||
summary = {
|
||||
"filename": filename,
|
||||
"id": material.get("id"),
|
||||
"name": material.get("name") or "未命名素材",
|
||||
"kind": material.get("kind"),
|
||||
"tags": _normalise_material_tags(material.get("tags") or []),
|
||||
"source_sha256": image.get("source_sha256", ""),
|
||||
"timestamp": material.get("timestamp", 0),
|
||||
"node_count": len(blocks),
|
||||
"node_types": sorted({block["type"] for block in blocks}),
|
||||
"selection": copy.deepcopy(material.get("selection")),
|
||||
"image": {
|
||||
"preview_asset_id": image.get("preview_asset_id"),
|
||||
"source_asset_id": image.get("source_asset_id"),
|
||||
"width": image.get("preview_width"),
|
||||
"height": image.get("preview_height"),
|
||||
},
|
||||
"capabilities": list(material.get("capabilities") or []),
|
||||
"source": {
|
||||
"type": source.get("type"),
|
||||
"label": str(source.get("parameter_name") or source.get("recipe_name") or source.get("notebook_name") or "").strip(),
|
||||
},
|
||||
"source_fingerprint": str(source.get("parameter_signature") or image.get("source_sha256") or ""),
|
||||
}
|
||||
if source.get("recipe_filename"):
|
||||
summary["source_recipe"] = {
|
||||
"filename": source.get("recipe_filename"),
|
||||
"name": str(source.get("recipe_name") or "").strip(),
|
||||
"fingerprint": str(source.get("recipe_fingerprint") or ""),
|
||||
}
|
||||
return summary
|
||||
|
||||
|
||||
def _workflow_hashes_for_blocks(workflow, blocks):
|
||||
hashes = (workflow.get("extra") or {}).get("anomalous_hashes") if isinstance(workflow, dict) else None
|
||||
if not isinstance(hashes, dict):
|
||||
return {}
|
||||
prefixes = tuple(f"{block.get('node_id')}_" for block in blocks)
|
||||
return {
|
||||
key: copy.deepcopy(value)
|
||||
for key, value in hashes.items()
|
||||
if isinstance(key, str) and key.startswith(prefixes)
|
||||
}
|
||||
|
||||
|
||||
def _normalise_material_tags(tags):
|
||||
if not isinstance(tags, list) or len(tags) > 20:
|
||||
raise ValueError("Invalid material tags")
|
||||
result = []
|
||||
seen = set()
|
||||
for tag in tags:
|
||||
if not isinstance(tag, str) or not tag.strip() or len(tag.strip()) > 60:
|
||||
raise ValueError("Invalid material tag")
|
||||
tag = tag.strip()
|
||||
if tag.casefold() not in seen:
|
||||
result.append(tag)
|
||||
seen.add(tag.casefold())
|
||||
return result
|
||||
|
||||
|
||||
def _normalise_prompt_note(data, prompt_only=False):
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Invalid prompt note")
|
||||
if len(json.dumps(data, ensure_ascii=False, allow_nan=False).encode("utf-8")) > MAX_NOTEBOOK_BYTES:
|
||||
raise ValueError("Prompt note is too large")
|
||||
result = {}
|
||||
for key in ("promptEn", "promptZh", "targetLang"):
|
||||
value = data.get(key, "")
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("Invalid prompt text")
|
||||
result[key] = value
|
||||
translations = data.get("translations", {})
|
||||
if not isinstance(translations, dict) or any(not isinstance(value, str) for value in translations.values()):
|
||||
raise ValueError("Invalid prompt translations")
|
||||
result["translations"] = copy.deepcopy(translations)
|
||||
if not prompt_only:
|
||||
base = data.get("baseModel", "")
|
||||
main = data.get("mainModel")
|
||||
loras = data.get("loras", [])
|
||||
if not isinstance(base, str) or (main is not None and not isinstance(main, dict)):
|
||||
raise ValueError("Invalid prompt models")
|
||||
if not isinstance(loras, list) or len(loras) > 100 or any(not isinstance(model, dict) for model in loras):
|
||||
raise ValueError("Invalid prompt LoRAs")
|
||||
for model in ([main] if main is not None else []) + loras:
|
||||
if not isinstance(model.get("filename"), str) or not model["filename"]:
|
||||
raise ValueError("Invalid prompt model filename")
|
||||
result.update(baseModel=base, mainModel=copy.deepcopy(main), loras=copy.deepcopy(loras))
|
||||
return result
|
||||
|
||||
|
||||
def _normalise_prompt_plan(plan):
|
||||
if not isinstance(plan, dict) or len(json.dumps(plan, ensure_ascii=False, allow_nan=False).encode("utf-8")) > MAX_NOTEBOOK_BYTES:
|
||||
raise ValueError("Invalid prompt plan")
|
||||
parts = plan.get("parts", [])
|
||||
if not isinstance(parts, list) or len(parts) > 100:
|
||||
raise ValueError("Invalid prompt parts")
|
||||
result = {"parts": []}
|
||||
if "version" in plan and isinstance(plan["version"], int):
|
||||
result["version"] = plan["version"]
|
||||
for key in ("positive", "negative"):
|
||||
if not isinstance(plan.get(key, ""), str):
|
||||
raise ValueError("Invalid prompt text")
|
||||
result[key] = plan.get(key, "")
|
||||
for part in parts:
|
||||
if not isinstance(part, dict) or part.get("category") not in ("general", "specific", "base", "style", "subject", "trigger") or not isinstance(part.get("enabled", True), bool):
|
||||
raise ValueError("Invalid prompt part")
|
||||
item = {"category": part["category"], "enabled": part.get("enabled", True)}
|
||||
if "role" in part and part["role"] in ("positive", "negative"):
|
||||
item["role"] = part["role"]
|
||||
if "id" in part and isinstance(part["id"], str) and len(part["id"]) <= 120:
|
||||
item["id"] = part["id"]
|
||||
for key in ("name", "positive", "negative"):
|
||||
if not isinstance(part.get(key, ""), str):
|
||||
raise ValueError("Invalid prompt part text")
|
||||
item[key] = part.get(key, "")
|
||||
if len(item["name"]) > 120:
|
||||
raise ValueError("Prompt part name is too long")
|
||||
result["parts"].append(item)
|
||||
return result
|
||||
|
||||
|
||||
def _material_category(material):
|
||||
if material.get("kind") == "image_workflow_snapshot":
|
||||
return "workflow"
|
||||
if material.get("kind") in ("prompt_text", "prompt_note_bundle", "prompt_plan"):
|
||||
return "prompts"
|
||||
types = material.get("node_types", [])
|
||||
if types and all(_is_prompt_node_type(value) for value in types):
|
||||
return "prompts"
|
||||
return "params"
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Single-owner persistence, cache, and locking for curated materials."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
from functools import lru_cache
|
||||
|
||||
import folder_paths
|
||||
|
||||
from .material_assets import _material_assets_dir, _store_assets
|
||||
from .material_schema import (
|
||||
_is_prompt_node_type,
|
||||
_material_category,
|
||||
_material_summary,
|
||||
_normalise_prompt_note,
|
||||
_normalise_prompt_plan,
|
||||
_recipe_link_fingerprint,
|
||||
)
|
||||
from .parameters import get_parameters_dir
|
||||
from .recipe_constants import MAX_RECIPE_BYTES
|
||||
from .recipe_store import _read_recipe, get_recipes_dir
|
||||
from .utils import atomic_write_json as _atomic_write_json, require_filename, resolve_within
|
||||
from .workflow_schema import _parameter_signature, _validate_workflow
|
||||
|
||||
|
||||
_material_write_lock = threading.RLock()
|
||||
|
||||
|
||||
def get_materials_dir():
|
||||
user_dir = (
|
||||
folder_paths.get_user_directory()
|
||||
if hasattr(folder_paths, "get_user_directory")
|
||||
else None
|
||||
)
|
||||
if not user_dir:
|
||||
user_dir = os.path.join(folder_paths.base_path, "user", "default")
|
||||
materials_dir = os.path.join(user_dir, "workflows", "anomalous_materials")
|
||||
os.makedirs(materials_dir, exist_ok=True)
|
||||
return materials_dir
|
||||
|
||||
|
||||
def _read_material(path):
|
||||
if os.path.getsize(path) > MAX_RECIPE_BYTES:
|
||||
raise ValueError("Material snapshot is too large")
|
||||
with open(path, "r", encoding="utf-8") as material_file:
|
||||
value = json.load(material_file)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("Invalid material snapshot")
|
||||
if value.get("kind") == "prompt_plan":
|
||||
_normalise_prompt_plan(value.get("plan"))
|
||||
elif value.get("kind") in ("prompt_note_bundle", "prompt_text"):
|
||||
_normalise_prompt_note(value.get("note"), value["kind"] == "prompt_text")
|
||||
elif not isinstance(value.get("workflow"), dict):
|
||||
raise ValueError("Invalid material snapshot")
|
||||
return value
|
||||
|
||||
|
||||
def _read_parameter_source(recipe_filename, parameter_filename=None):
|
||||
recipe_filename = require_filename(recipe_filename)
|
||||
if not recipe_filename.endswith(".json") or recipe_filename.startswith("."):
|
||||
raise ValueError("Invalid recipe filename")
|
||||
recipe_path = resolve_within(get_recipes_dir(), recipe_filename)
|
||||
if os.path.getsize(recipe_path) > MAX_RECIPE_BYTES:
|
||||
raise ValueError("Recipe source is too large")
|
||||
recipe = _read_recipe(recipe_path)
|
||||
source = recipe
|
||||
if parameter_filename:
|
||||
parameter_filename = require_filename(parameter_filename)
|
||||
if not parameter_filename.endswith(".json") or parameter_filename.startswith("."):
|
||||
raise ValueError("Invalid parameter filename")
|
||||
parameter_path = resolve_within(get_parameters_dir(), parameter_filename)
|
||||
if os.path.getsize(parameter_path) > MAX_RECIPE_BYTES:
|
||||
raise ValueError("Parameter notebook is too large")
|
||||
with open(parameter_path, "r", encoding="utf-8") as parameter_file:
|
||||
source = json.load(parameter_file)
|
||||
if not isinstance(source, dict) or source.get("recipe_filename") != recipe_filename:
|
||||
raise ValueError("Parameter notebook does not belong to recipe")
|
||||
workflow = source.get("workflow") if isinstance(source, dict) else None
|
||||
if not isinstance(workflow, dict):
|
||||
raise ValueError("Parameter source has no workflow")
|
||||
_validate_workflow(workflow)
|
||||
return recipe_filename, parameter_filename, recipe, source, workflow
|
||||
|
||||
|
||||
def _parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow):
|
||||
record = {
|
||||
"type": "recipe_parameter_notebook" if parameter_filename else "workflow_recipe",
|
||||
**_snapshot_recipe_source(recipe_filename),
|
||||
"parameter_signature": (_parameter_signature(workflow) or {}).get("value") or "",
|
||||
}
|
||||
if parameter_filename:
|
||||
record["parameter_filename"] = parameter_filename
|
||||
record["parameter_name"] = str(source.get("name") or "").strip()[:200]
|
||||
elif isinstance(recipe, dict):
|
||||
record["parameter_name"] = str(recipe.get("name") or "").strip()[:200]
|
||||
return record
|
||||
|
||||
|
||||
def _snapshot_recipe_source(recipe_filename):
|
||||
if not recipe_filename:
|
||||
return {}
|
||||
record = {"recipe_filename": recipe_filename}
|
||||
try:
|
||||
path = resolve_within(get_recipes_dir(), require_filename(recipe_filename))
|
||||
recipe = _read_recipe(path)
|
||||
except (OSError, ValueError, json.JSONDecodeError, TypeError):
|
||||
return record
|
||||
if not isinstance(recipe, dict):
|
||||
return record
|
||||
name = str(recipe.get("name") or "").strip()
|
||||
if name:
|
||||
record["recipe_name"] = name[:120]
|
||||
fingerprint = _recipe_link_fingerprint(recipe)
|
||||
if fingerprint:
|
||||
record["recipe_fingerprint"] = fingerprint
|
||||
return record
|
||||
|
||||
|
||||
def _live_recipe_source(source):
|
||||
if not isinstance(source, dict):
|
||||
return None
|
||||
filename = source.get("recipe_filename")
|
||||
if not isinstance(filename, str) or not filename:
|
||||
return None
|
||||
info = {
|
||||
"filename": filename,
|
||||
"name": str(source.get("recipe_name") or "").strip(),
|
||||
"status": "missing",
|
||||
}
|
||||
try:
|
||||
path = resolve_within(get_recipes_dir(), require_filename(filename))
|
||||
if not os.path.isfile(path):
|
||||
return info
|
||||
recipe = _read_recipe(path)
|
||||
except (OSError, ValueError, json.JSONDecodeError, TypeError):
|
||||
return info
|
||||
if isinstance(recipe, dict):
|
||||
live_name = str(recipe.get("name") or "").strip()
|
||||
if live_name:
|
||||
info["name"] = live_name[:120]
|
||||
snapshot = str(source.get("recipe_fingerprint") or "")
|
||||
current = _recipe_link_fingerprint(recipe)
|
||||
if snapshot and current and snapshot != current:
|
||||
info["status"] = "modified"
|
||||
else:
|
||||
info["status"] = "current"
|
||||
return info
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _cached_material_summary(path, signature):
|
||||
return _material_summary(os.path.basename(path), _read_material(path))
|
||||
|
||||
|
||||
def _with_live_recipe_source(summary):
|
||||
snapshot = summary.get("source_recipe")
|
||||
if not isinstance(snapshot, dict) or not snapshot.get("filename"):
|
||||
return summary
|
||||
summary["source_recipe"] = _live_recipe_source({
|
||||
"recipe_filename": snapshot.get("filename"),
|
||||
"recipe_name": snapshot.get("name"),
|
||||
"recipe_fingerprint": snapshot.get("fingerprint"),
|
||||
})
|
||||
return summary
|
||||
|
||||
|
||||
def _summary_for_path(path, stat=None):
|
||||
stat = stat or os.stat(path)
|
||||
signature = (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
|
||||
return _with_live_recipe_source(copy.deepcopy(_cached_material_summary(path, signature)))
|
||||
|
||||
|
||||
def _list_materials(materials_dir):
|
||||
result = []
|
||||
try:
|
||||
materials_dir = os.path.realpath(materials_dir)
|
||||
entries = os.scandir(materials_dir)
|
||||
except OSError:
|
||||
return result
|
||||
with entries:
|
||||
for entry in entries:
|
||||
if not entry.is_file() or not entry.name.endswith(".json") or entry.name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
path = resolve_within(materials_dir, entry.name) if entry.is_symlink() else entry.path
|
||||
result.append(copy.deepcopy(_summary_for_path(path, entry.stat())))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
result.sort(key=lambda item: (item.get("timestamp", 0), item["filename"]), reverse=True)
|
||||
return result
|
||||
|
||||
|
||||
def _persist_material(materials_dir, filename, source_path, material, allow_duplicate=False):
|
||||
with _material_write_lock:
|
||||
return _persist_material_files(materials_dir, filename, source_path, material, allow_duplicate)
|
||||
|
||||
|
||||
def _persist_parameter_material(materials_dir, filename, material, allow_duplicate=False):
|
||||
with _material_write_lock:
|
||||
target = resolve_within(materials_dir, filename)
|
||||
if os.path.exists(target):
|
||||
raise ValueError("Material already exists")
|
||||
if not allow_duplicate:
|
||||
selection = sorted(str(value) for value in (material.get("selection") or {}).get("node_ids", []))
|
||||
fingerprint = str((material.get("source") or {}).get("parameter_signature") or "")
|
||||
for summary in _list_materials(materials_dir):
|
||||
candidate_selection = sorted(str(value) for value in (summary.get("selection") or {}).get("node_ids", []))
|
||||
if (summary.get("kind") == material.get("kind")
|
||||
and summary.get("source_fingerprint") == fingerprint
|
||||
and candidate_selection == selection):
|
||||
return summary
|
||||
_atomic_write_json(target, material)
|
||||
return None
|
||||
|
||||
|
||||
def _persist_material_files(materials_dir, filename, source_path, material, allow_duplicate):
|
||||
target = resolve_within(materials_dir, filename)
|
||||
assets_target = _material_assets_dir(materials_dir, filename)
|
||||
if os.path.exists(target) or os.path.exists(assets_target):
|
||||
raise ValueError("Material already exists")
|
||||
with tempfile.TemporaryDirectory(prefix=".save-", dir=materials_dir) as staging:
|
||||
material["image"] = _store_assets(staging, filename, source_path)
|
||||
if not allow_duplicate:
|
||||
selection = sorted(str(value) for value in (material.get("selection") or {}).get("node_ids", []))
|
||||
for summary in _list_materials(materials_dir):
|
||||
candidate_selection = sorted(str(value) for value in (summary.get("selection") or {}).get("node_ids", []))
|
||||
if summary.get("source_sha256") == material["image"]["source_sha256"] and summary.get("kind") == material.get("kind") and candidate_selection == selection:
|
||||
return summary
|
||||
staged_json = resolve_within(staging, filename)
|
||||
_atomic_write_json(staged_json, material)
|
||||
os.makedirs(os.path.dirname(assets_target), exist_ok=True)
|
||||
os.replace(_material_assets_dir(staging, filename), assets_target)
|
||||
try:
|
||||
os.replace(staged_json, target)
|
||||
except OSError:
|
||||
shutil.rmtree(assets_target)
|
||||
raise
|
||||
|
||||
|
||||
def _query_materials(materials_dir, query):
|
||||
materials = _list_materials(materials_dir)
|
||||
all_tags = sorted({tag for material in materials for tag in material.get("tags", [])}, key=str.casefold)
|
||||
search = query.get("q", "").strip().casefold()
|
||||
tag = query.get("tag", "").strip().casefold()
|
||||
kind = query.get("kind", "")
|
||||
category = query.get("category", "all")
|
||||
if category not in ("all", "workflow", "params", "prompts"):
|
||||
raise ValueError("Invalid material category")
|
||||
node_type = query.get("node_type", "")
|
||||
if len(node_type) > 200:
|
||||
raise ValueError("Invalid node type")
|
||||
if len(search) > 200 or len(tag) > 60 or kind not in (
|
||||
"", "image_workflow_snapshot", "image_node_selection", "recipe_parameter_selection", "prompt_note_bundle", "prompt_text", "prompt_plan"
|
||||
):
|
||||
raise ValueError("Invalid material filter")
|
||||
materials = [material for material in materials
|
||||
if (not search or search in " ".join([material["name"], *material["node_types"], *material.get("tags", [])]).casefold())
|
||||
and (not tag or tag in [value.casefold() for value in material.get("tags", [])])
|
||||
and (not kind or material["kind"] == kind)
|
||||
and (category == "all" or _material_category(material) == category)
|
||||
and (not node_type or node_type in material["node_types"])]
|
||||
total = len(materials)
|
||||
limit = min(100, max(1, int(query.get("limit", 48))))
|
||||
pages = max(1, (total + limit - 1) // limit)
|
||||
page = min(pages, max(1, int(query.get("page", 1))))
|
||||
# Existing non-paginated callers retain their response; the library requests pages.
|
||||
if "page" in query or "limit" in query:
|
||||
materials = materials[(page - 1) * limit:page * limit]
|
||||
return {"status": "success", "materials": materials, "total": total,
|
||||
"page": page, "pages": pages, "tags": all_tags}
|
||||
|
||||
|
||||
def _update_material_details(materials_dir, filename, name, tags, prompt_role_overrides=None,
|
||||
update_prompt_roles=False):
|
||||
with _material_write_lock:
|
||||
path = resolve_within(materials_dir, filename)
|
||||
material = _read_material(path)
|
||||
material.update(name=name, tags=tags)
|
||||
if update_prompt_roles:
|
||||
if prompt_role_overrides:
|
||||
material["promptRoleOverrides"] = prompt_role_overrides
|
||||
else:
|
||||
material.pop("promptRoleOverrides", None)
|
||||
_atomic_write_json(path, material)
|
||||
return _with_live_recipe_source(_material_summary(filename, material))
|
||||
|
||||
|
||||
def _delete_material(materials_dir, filename, path):
|
||||
with _material_write_lock:
|
||||
_read_material(path)
|
||||
os.remove(path)
|
||||
assets_dir = _material_assets_dir(materials_dir, filename)
|
||||
if os.path.isdir(assets_dir):
|
||||
shutil.rmtree(assets_dir)
|
||||
@@ -0,0 +1,574 @@
|
||||
"""HTTP routes and compatibility facade for curated material modules."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from . import material_schema as _schema
|
||||
from . import material_store as _store
|
||||
from .material_assets import _inspect_source_image, _material_assets_dir, _store_assets
|
||||
from .material_schema import (
|
||||
MAX_MATERIAL_NAME_LENGTH,
|
||||
_extract_workflow_params,
|
||||
_is_prompt_node_type,
|
||||
_material_category,
|
||||
_material_node_blocks,
|
||||
_material_node_ids,
|
||||
_material_summary,
|
||||
_node_blocks,
|
||||
_normalise_material_tags,
|
||||
_normalise_prompt_role_overrides,
|
||||
_normalise_selected_node_ids,
|
||||
_parameter_signature,
|
||||
_prompt_excerpt,
|
||||
_prompt_groups_from_roles,
|
||||
_prompt_roles_for_workflow,
|
||||
_workflow_hashes_for_blocks,
|
||||
)
|
||||
from .notebooks import MAX_NOTEBOOK_BYTES
|
||||
from .parameters import get_parameters_dir
|
||||
from .recipe_constants import MAX_RECIPE_BYTES
|
||||
from .recipe_schema import _build_model_references
|
||||
from .recipe_store import get_recipes_dir
|
||||
from .utils import atomic_write_json as _atomic_write_json, require_filename, resolve_within
|
||||
|
||||
|
||||
MATERIAL_SCHEMA_VERSION = 1
|
||||
|
||||
_store_get_materials_dir = _store.get_materials_dir
|
||||
_store_read_material = _store._read_material
|
||||
_store_read_parameter_source = _store._read_parameter_source
|
||||
_store_parameter_source_record = _store._parameter_source_record
|
||||
_store_snapshot_recipe_source = _store._snapshot_recipe_source
|
||||
_store_live_recipe_source = _store._live_recipe_source
|
||||
_store_with_live_recipe_source = _store._with_live_recipe_source
|
||||
_store_summary_for_path = _store._summary_for_path
|
||||
_store_list_materials = _store._list_materials
|
||||
_store_persist_material = _store._persist_material
|
||||
_store_persist_parameter_material = _store._persist_parameter_material
|
||||
_store_query_materials = _store._query_materials
|
||||
_store_update_material_details = _store._update_material_details
|
||||
_store_delete_material = _store._delete_material
|
||||
|
||||
|
||||
def get_materials_dir():
|
||||
return _store_get_materials_dir()
|
||||
|
||||
|
||||
def _sync_store_compatibility():
|
||||
"""Keep historical monkeypatch points effective while storage has one owner."""
|
||||
_store._atomic_write_json = _atomic_write_json
|
||||
_store._read_material = _read_material
|
||||
_store._material_summary = _material_summary
|
||||
_store._normalise_prompt_note = _normalise_prompt_note
|
||||
_store._normalise_prompt_plan = _normalise_prompt_plan
|
||||
_store._material_assets_dir = _material_assets_dir
|
||||
_store._store_assets = _store_assets
|
||||
_store.get_materials_dir = get_materials_dir
|
||||
|
||||
|
||||
def _normalise_prompt_note(data, prompt_only=False):
|
||||
_schema.MAX_NOTEBOOK_BYTES = MAX_NOTEBOOK_BYTES
|
||||
return _schema._normalise_prompt_note(data, prompt_only)
|
||||
|
||||
|
||||
def _normalise_prompt_plan(plan):
|
||||
_schema.MAX_NOTEBOOK_BYTES = MAX_NOTEBOOK_BYTES
|
||||
return _schema._normalise_prompt_plan(plan)
|
||||
|
||||
|
||||
def _read_material(path):
|
||||
_store._normalise_prompt_note = _normalise_prompt_note
|
||||
_store._normalise_prompt_plan = _normalise_prompt_plan
|
||||
return _store_read_material(path)
|
||||
|
||||
|
||||
def _read_parameter_source(recipe_filename, parameter_filename=None):
|
||||
_store.get_recipes_dir = get_recipes_dir
|
||||
_store.get_parameters_dir = get_parameters_dir
|
||||
return _store_read_parameter_source(recipe_filename, parameter_filename)
|
||||
|
||||
|
||||
def _parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow):
|
||||
return _store_parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow)
|
||||
|
||||
|
||||
def _snapshot_recipe_source(filename):
|
||||
_store.get_recipes_dir = get_recipes_dir
|
||||
return _store_snapshot_recipe_source(filename)
|
||||
|
||||
|
||||
def _live_recipe_source(source):
|
||||
_store.get_recipes_dir = get_recipes_dir
|
||||
return _store_live_recipe_source(source)
|
||||
|
||||
|
||||
_cached_material_summary = _store._cached_material_summary
|
||||
|
||||
|
||||
def _with_live_recipe_source(summary):
|
||||
_store._live_recipe_source = _live_recipe_source
|
||||
return _store_with_live_recipe_source(summary)
|
||||
|
||||
|
||||
def _summary_for_path(path, stat=None):
|
||||
_sync_store_compatibility()
|
||||
_store._with_live_recipe_source = _with_live_recipe_source
|
||||
return _store_summary_for_path(path, stat)
|
||||
|
||||
|
||||
def _list_materials(materials_dir):
|
||||
_sync_store_compatibility()
|
||||
_store._summary_for_path = _summary_for_path
|
||||
return _store_list_materials(materials_dir)
|
||||
|
||||
|
||||
def _persist_material(materials_dir, filename, source_path, material, allow_duplicate=False):
|
||||
_sync_store_compatibility()
|
||||
_store._list_materials = _list_materials
|
||||
return _store_persist_material(materials_dir, filename, source_path, material, allow_duplicate)
|
||||
|
||||
|
||||
def _persist_parameter_material(materials_dir, filename, material, allow_duplicate=False):
|
||||
_sync_store_compatibility()
|
||||
_store._list_materials = _list_materials
|
||||
return _store_persist_parameter_material(materials_dir, filename, material, allow_duplicate)
|
||||
|
||||
|
||||
def _query_materials(materials_dir, query):
|
||||
_sync_store_compatibility()
|
||||
_store._list_materials = _list_materials
|
||||
return _store_query_materials(materials_dir, query)
|
||||
|
||||
|
||||
def _update_material_details(materials_dir, filename, name, tags, prompt_role_overrides=None,
|
||||
update_prompt_roles=False):
|
||||
_sync_store_compatibility()
|
||||
_store._with_live_recipe_source = _with_live_recipe_source
|
||||
return _store_update_material_details(
|
||||
materials_dir, filename, name, tags, prompt_role_overrides, update_prompt_roles
|
||||
)
|
||||
|
||||
|
||||
def _delete_material(materials_dir, filename, path):
|
||||
_sync_store_compatibility()
|
||||
return _store_delete_material(materials_dir, filename, path)
|
||||
|
||||
|
||||
async def api_inspect_image_material(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
source, source_path, workflow, blocks, references, suggested_name = await asyncio.to_thread(
|
||||
_inspect_source_image, payload.get("source_image")
|
||||
)
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Image has no reusable UI workflow"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Output image not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not inspect output image"}, status=500)
|
||||
sampling_params, prompts = _extract_workflow_params(workflow)
|
||||
prompt_roles = _prompt_roles_for_workflow(workflow)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"source_image": source,
|
||||
"suggested_name": suggested_name,
|
||||
"node_count": len(blocks),
|
||||
"node_blocks": blocks,
|
||||
"workflow": workflow,
|
||||
"model_references": references,
|
||||
"prompt_excerpt": _prompt_excerpt(workflow),
|
||||
"params": sampling_params,
|
||||
"prompts": prompts,
|
||||
"prompt_roles": prompt_roles,
|
||||
"prompt_groups": _prompt_groups_from_roles(workflow, prompt_roles),
|
||||
})
|
||||
|
||||
|
||||
async def api_save_image_material(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
source, source_path, workflow, blocks, references, suggested_name = await asyncio.to_thread(
|
||||
_inspect_source_image, payload.get("source_image")
|
||||
)
|
||||
name = payload.get("name") or suggested_name
|
||||
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_MATERIAL_NAME_LENGTH:
|
||||
raise ValueError("Invalid material name")
|
||||
tags = _normalise_material_tags(payload.get("tags", []))
|
||||
allow_duplicate = payload.get("allow_duplicate", False)
|
||||
if not isinstance(allow_duplicate, bool):
|
||||
raise ValueError("Invalid duplicate preference")
|
||||
selected_node_ids = _normalise_selected_node_ids(workflow, payload.get("selected_node_ids"))
|
||||
selected_id_keys = {str(node_id) for node_id in selected_node_ids or []}
|
||||
if selected_node_ids is not None:
|
||||
blocks = [block for block in blocks if str(block.get("node_id")) in selected_id_keys]
|
||||
references = [
|
||||
reference for reference in references
|
||||
if str(reference.get("node_id")) in selected_id_keys
|
||||
]
|
||||
material_id = uuid.uuid4().hex
|
||||
filename = f"material_{int(time.time())}_{material_id}.json"
|
||||
materials_dir = get_materials_dir()
|
||||
now = int(time.time() * 1000)
|
||||
source_record = {"type": "generated_image", "image": source}
|
||||
recipe_filename = payload.get("recipe_filename")
|
||||
if isinstance(recipe_filename, str) and recipe_filename.strip():
|
||||
try:
|
||||
recipe_filename = require_filename(recipe_filename.strip())
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
recipe_filename = ""
|
||||
if recipe_filename.endswith(".json"):
|
||||
source_record.update(_snapshot_recipe_source(recipe_filename))
|
||||
prompt_role_overrides = _normalise_prompt_role_overrides(payload.get("promptRoleOverrides"))
|
||||
material = {
|
||||
"schema_version": MATERIAL_SCHEMA_VERSION,
|
||||
"id": material_id,
|
||||
"kind": "image_node_selection" if selected_node_ids is not None else "image_workflow_snapshot",
|
||||
"name": name,
|
||||
"tags": tags,
|
||||
"timestamp": now,
|
||||
"source": source_record,
|
||||
"workflow": workflow,
|
||||
"model_references": references,
|
||||
"capabilities": (
|
||||
["apply_node_parameters", "reference_image"]
|
||||
if selected_node_ids is not None
|
||||
else ["open_workflow", "apply_node_parameters", "reference_image"]
|
||||
),
|
||||
}
|
||||
if selected_node_ids is not None:
|
||||
material["selection"] = {"scope": "nodes", "node_ids": selected_node_ids}
|
||||
if prompt_role_overrides:
|
||||
material["promptRoleOverrides"] = prompt_role_overrides
|
||||
duplicate = await asyncio.to_thread(_persist_material, materials_dir, filename, source_path, material, allow_duplicate)
|
||||
if duplicate:
|
||||
return web.json_response({"status": "duplicate", "filename": duplicate["filename"], "name": duplicate["name"]}, status=409)
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Could not save image material"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Output image not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not save image material"}, status=500)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"filename": filename,
|
||||
"material": _with_live_recipe_source(_material_summary(filename, material)),
|
||||
"node_blocks": blocks,
|
||||
})
|
||||
|
||||
|
||||
async def api_save_parameter_material(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
recipe_filename, parameter_filename, recipe, source, workflow = await asyncio.to_thread(
|
||||
_read_parameter_source,
|
||||
payload.get("recipe_filename", ""),
|
||||
payload.get("parameter_filename"),
|
||||
)
|
||||
source_name = str(source.get("name") or recipe.get("name") or "参数素材").strip()
|
||||
name = payload.get("name") or source_name
|
||||
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_MATERIAL_NAME_LENGTH:
|
||||
raise ValueError("Invalid material name")
|
||||
tags = _normalise_material_tags(payload.get("tags", recipe.get("tags") or []))
|
||||
allow_duplicate = payload.get("allow_duplicate", False)
|
||||
if not isinstance(allow_duplicate, bool):
|
||||
raise ValueError("Invalid duplicate preference")
|
||||
|
||||
reusable_ids = [
|
||||
node.get("id") for node in workflow.get("nodes", [])
|
||||
if isinstance(node, dict) and node.get("id") is not None
|
||||
and isinstance(node.get("widgets_values"), list) and node.get("widgets_values")
|
||||
]
|
||||
raw_selection = payload.get("selected_node_ids")
|
||||
selected_node_ids = _normalise_selected_node_ids(
|
||||
workflow,
|
||||
reusable_ids if raw_selection is None else raw_selection,
|
||||
)
|
||||
reusable_keys = {str(value) for value in reusable_ids}
|
||||
if any(str(value) not in reusable_keys for value in selected_node_ids):
|
||||
raise ValueError("Selected node has no reusable parameters")
|
||||
selected_keys = {str(value) for value in selected_node_ids}
|
||||
blocks = [
|
||||
block for block in _node_blocks(workflow, include_values=False)
|
||||
if str(block.get("node_id")) in selected_keys
|
||||
]
|
||||
references = [
|
||||
reference for reference in _build_model_references(source, verify_identities=False)
|
||||
if str(reference.get("node_id")) in selected_keys
|
||||
]
|
||||
source_record = _parameter_source_record(
|
||||
recipe_filename, parameter_filename, recipe, source, workflow
|
||||
)
|
||||
material_id = uuid.uuid4().hex
|
||||
filename = f"material_{int(time.time())}_{material_id}.json"
|
||||
material = {
|
||||
"schema_version": MATERIAL_SCHEMA_VERSION,
|
||||
"id": material_id,
|
||||
"kind": "recipe_parameter_selection",
|
||||
"name": name,
|
||||
"tags": tags,
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"source": source_record,
|
||||
"workflow": workflow,
|
||||
"model_references": references,
|
||||
"capabilities": ["apply_node_parameters"],
|
||||
"selection": {"scope": "nodes", "node_ids": selected_node_ids},
|
||||
}
|
||||
params = source.get("params") if isinstance(source.get("params"), dict) else {}
|
||||
prompt_role_overrides = _normalise_prompt_role_overrides(params.get("promptRoleOverrides"))
|
||||
if prompt_role_overrides:
|
||||
material["promptRoleOverrides"] = prompt_role_overrides
|
||||
materials_dir = get_materials_dir()
|
||||
duplicate = await asyncio.to_thread(
|
||||
_persist_parameter_material, materials_dir, filename, material, allow_duplicate
|
||||
)
|
||||
if duplicate:
|
||||
return web.json_response({
|
||||
"status": "duplicate",
|
||||
"filename": duplicate["filename"],
|
||||
"name": duplicate["name"],
|
||||
}, status=409)
|
||||
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Could not save parameter material"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Parameter source not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not save parameter material"}, status=500)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"filename": filename,
|
||||
"material": _with_live_recipe_source(_material_summary(filename, material)),
|
||||
"node_blocks": blocks,
|
||||
})
|
||||
|
||||
|
||||
async def api_save_prompt_note_material(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
scope = payload.get("scope", "note")
|
||||
if scope not in ("note", "prompt"):
|
||||
raise ValueError("Invalid prompt scope")
|
||||
source_filename = require_filename(payload.get("notebook_filename", ""))
|
||||
if not source_filename.endswith(".json") or source_filename.startswith("."):
|
||||
raise ValueError("Invalid notebook filename")
|
||||
name = payload.get("name", "")
|
||||
if not isinstance(name, str) or not name.strip() or len(name.strip()) > MAX_MATERIAL_NAME_LENGTH:
|
||||
raise ValueError("Invalid material name")
|
||||
note = _normalise_prompt_note(payload.get("note"), scope == "prompt")
|
||||
if scope == "prompt" and not note["promptEn"].strip():
|
||||
raise ValueError("Prompt is empty")
|
||||
tags = _normalise_material_tags(payload.get("tags", []))
|
||||
allow_duplicate = payload.get("allow_duplicate", False)
|
||||
if not isinstance(allow_duplicate, bool):
|
||||
raise ValueError("Invalid duplicate preference")
|
||||
signature = hashlib.sha256(json.dumps(note, ensure_ascii=False, sort_keys=True, allow_nan=False).encode("utf-8")).hexdigest()
|
||||
material_id = uuid.uuid4().hex
|
||||
filename = f"material_{int(time.time())}_{material_id}.json"
|
||||
material = {
|
||||
"schema_version": MATERIAL_SCHEMA_VERSION, "id": material_id,
|
||||
"kind": "prompt_text" if scope == "prompt" else "prompt_note_bundle",
|
||||
"name": name.strip(), "tags": tags, "timestamp": int(time.time() * 1000),
|
||||
"source": {"type": "prompt_note", "notebook_filename": source_filename,
|
||||
"notebook_name": name.strip(), "parameter_signature": signature},
|
||||
"note": note, "selection": {"scope": scope},
|
||||
"capabilities": ["copy_prompt", "restore_prompt_note"],
|
||||
}
|
||||
duplicate = await asyncio.to_thread(
|
||||
_persist_parameter_material, get_materials_dir(), filename, material, allow_duplicate
|
||||
)
|
||||
if duplicate:
|
||||
return web.json_response({"status": "duplicate", "filename": duplicate["filename"],
|
||||
"name": duplicate["name"]}, status=409)
|
||||
return web.json_response({"status": "success", "filename": filename,
|
||||
"material": _material_summary(filename, material)})
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid prompt note material"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not save prompt note material"}, status=500)
|
||||
|
||||
|
||||
async def api_save_prompt_plan(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
name = payload.get("name", "")
|
||||
if not isinstance(name, str) or not name.strip() or len(name.strip()) > MAX_MATERIAL_NAME_LENGTH:
|
||||
raise ValueError("Invalid plan name")
|
||||
plan = _normalise_prompt_plan(payload.get("plan"))
|
||||
tags = _normalise_material_tags(payload.get("tags", []))
|
||||
allow_duplicate = payload.get("allow_duplicate", False)
|
||||
if not isinstance(allow_duplicate, bool):
|
||||
raise ValueError("Invalid duplicate preference")
|
||||
signature = hashlib.sha256(json.dumps(plan, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
|
||||
material_id = uuid.uuid4().hex
|
||||
filename = f"material_{int(time.time())}_{material_id}.json"
|
||||
material = {"schema_version": MATERIAL_SCHEMA_VERSION, "id": material_id,
|
||||
"kind": "prompt_plan", "name": name.strip(), "tags": tags,
|
||||
"timestamp": int(time.time() * 1000), "plan": plan,
|
||||
"source": {"type": "prompt_plan", "parameter_signature": signature},
|
||||
"capabilities": ["compose_prompt"], "selection": {"scope": "prompt_plan"}}
|
||||
duplicate = await asyncio.to_thread(_persist_parameter_material, get_materials_dir(), filename, material, allow_duplicate)
|
||||
if duplicate:
|
||||
return web.json_response({"status": "duplicate", "filename": duplicate["filename"], "name": duplicate["name"]}, status=409)
|
||||
return web.json_response({"status": "success", "filename": filename, "material": _material_summary(filename, material)})
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid prompt plan"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not save prompt plan"}, status=500)
|
||||
|
||||
|
||||
async def api_get_materials(request):
|
||||
try:
|
||||
payload = await asyncio.to_thread(_query_materials, get_materials_dir(), dict(request.query))
|
||||
return web.json_response(payload)
|
||||
except (TypeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid material filter"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not list materials"}, status=500)
|
||||
|
||||
|
||||
async def api_update_material(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
name = payload.get("name", "")
|
||||
if not filename.endswith(".json") or filename.startswith(".") or not isinstance(name, str) or not name.strip() or len(name.strip()) > MAX_MATERIAL_NAME_LENGTH:
|
||||
raise ValueError("Invalid material details")
|
||||
tags = _normalise_material_tags(payload.get("tags", []))
|
||||
update_prompt_roles = "promptRoleOverrides" in payload
|
||||
prompt_role_overrides = (
|
||||
_normalise_prompt_role_overrides(payload.get("promptRoleOverrides"))
|
||||
if update_prompt_roles else None
|
||||
)
|
||||
material = await asyncio.to_thread(
|
||||
_update_material_details,
|
||||
get_materials_dir(),
|
||||
filename,
|
||||
name.strip(),
|
||||
tags,
|
||||
prompt_role_overrides,
|
||||
update_prompt_roles,
|
||||
)
|
||||
return web.json_response({"status": "success", "material": material})
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid material details"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Material not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not update material"}, status=500)
|
||||
|
||||
|
||||
def _material_detail_response(path, include_workflow):
|
||||
material = _read_material(path)
|
||||
can_open = (material.get("kind") == "image_workflow_snapshot"
|
||||
and (material.get("selection") or {}).get("scope") != "nodes"
|
||||
and "open_workflow" in material.get("capabilities", []))
|
||||
if include_workflow == "1" and not can_open:
|
||||
return web.json_response({"status": "error", "message": "Material cannot open a full workflow"}, status=403)
|
||||
workflow = material.get("workflow") or {}
|
||||
prompt_roles = _prompt_roles_for_workflow(workflow, material.get("promptRoleOverrides"))
|
||||
prompt_groups = _prompt_groups_from_roles(workflow, prompt_roles, _material_node_ids(material))
|
||||
blocks = _material_node_blocks(material, include_values=True) if include_workflow != "1" else []
|
||||
if include_workflow == "0" or not can_open:
|
||||
material.pop("workflow", None)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"data": material,
|
||||
"source_recipe": _live_recipe_source(material.get("source") or {}),
|
||||
"node_blocks": blocks,
|
||||
"workflow_hashes": _workflow_hashes_for_blocks(workflow, blocks),
|
||||
"prompt_roles": prompt_roles,
|
||||
"prompt_groups": prompt_groups,
|
||||
})
|
||||
|
||||
|
||||
async def api_get_material_full(request):
|
||||
try:
|
||||
filename = require_filename(request.query.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid material filename")
|
||||
include_workflow = request.query.get("include_workflow")
|
||||
if include_workflow not in (None, "0", "1"):
|
||||
raise ValueError("Invalid workflow inclusion flag")
|
||||
return await asyncio.to_thread(_material_detail_response,
|
||||
resolve_within(get_materials_dir(), filename), include_workflow)
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid material"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Material not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read material"}, status=500)
|
||||
|
||||
|
||||
async def api_get_materials_by_node_type(request):
|
||||
node_type = request.query.get("type", "")
|
||||
if not isinstance(node_type, str) or not node_type.strip() or len(node_type) > 200:
|
||||
return web.json_response({"status": "error", "message": "Invalid node type"}, status=400)
|
||||
materials_dir = get_materials_dir()
|
||||
|
||||
def collect():
|
||||
matches = []
|
||||
for summary in _list_materials(materials_dir):
|
||||
if node_type not in summary["node_types"]:
|
||||
continue
|
||||
try:
|
||||
material = _read_material(resolve_within(materials_dir, summary["filename"]))
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
blocks = [block for block in _material_node_blocks(material, include_values=True) if block["type"] == node_type]
|
||||
if blocks:
|
||||
matches.append({
|
||||
"filename": summary["filename"],
|
||||
"name": summary["name"],
|
||||
"kind": summary["kind"],
|
||||
"timestamp": summary["timestamp"],
|
||||
"blocks": blocks,
|
||||
"workflow_hashes": _workflow_hashes_for_blocks(material["workflow"], blocks),
|
||||
})
|
||||
return matches
|
||||
|
||||
matches = await asyncio.to_thread(collect)
|
||||
return web.json_response({"status": "success", "materials": matches})
|
||||
|
||||
|
||||
async def api_get_material_asset(request):
|
||||
try:
|
||||
filename = require_filename(request.query.get("filename", ""))
|
||||
asset_id = require_filename(request.query.get("asset", ""))
|
||||
if not filename.endswith(".json") or not asset_id.lower().endswith((".png", ".webp")):
|
||||
raise ValueError("Invalid material asset")
|
||||
materials_dir = get_materials_dir()
|
||||
await asyncio.to_thread(_summary_for_path, resolve_within(materials_dir, filename))
|
||||
asset_path = resolve_within(_material_assets_dir(materials_dir, filename), asset_id)
|
||||
if not os.path.isfile(asset_path):
|
||||
raise FileNotFoundError
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid material asset"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Material asset not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read material asset"}, status=500)
|
||||
return web.FileResponse(asset_path)
|
||||
|
||||
|
||||
async def api_delete_material(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid material filename")
|
||||
materials_dir = get_materials_dir()
|
||||
path = resolve_within(materials_dir, filename)
|
||||
await asyncio.to_thread(_delete_material, materials_dir, filename, path)
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid material"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Material not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not delete material"}, status=500)
|
||||
return web.json_response({"status": "success"})
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Model cover, thumbnail-cache, and generated-image lookup routes."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .path_utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
|
||||
|
||||
CARD_THUMBNAIL_EDGE = 512
|
||||
CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024
|
||||
CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'}
|
||||
_thumbnail_cleanup_lock = threading.Lock()
|
||||
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
|
||||
_thumbnail_last_cleanup = 0.0
|
||||
_thumbnail_created_since_cleanup = 0
|
||||
|
||||
|
||||
def _thumbnail_cache_directory():
|
||||
try:
|
||||
base_dir = folder_paths.get_temp_directory()
|
||||
except Exception:
|
||||
base_dir = None
|
||||
if not isinstance(base_dir, (str, os.PathLike)):
|
||||
base_dir = tempfile.gettempdir()
|
||||
cache_dir = os.path.join(base_dir, "anomalous_model_browser", "card_thumbnails")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
|
||||
def _prune_thumbnail_cache(cache_dir, created=False):
|
||||
"""Occasionally cap the derived thumbnail cache without touching source covers."""
|
||||
global _thumbnail_last_cleanup, _thumbnail_created_since_cleanup
|
||||
now = time.monotonic()
|
||||
with _thumbnail_cleanup_lock:
|
||||
if created:
|
||||
_thumbnail_created_since_cleanup += 1
|
||||
now = time.monotonic()
|
||||
if (
|
||||
now - _thumbnail_last_cleanup < 3600
|
||||
and _thumbnail_created_since_cleanup < 64
|
||||
):
|
||||
return
|
||||
_thumbnail_last_cleanup = now
|
||||
_thumbnail_created_since_cleanup = 0
|
||||
entries = []
|
||||
total_size = 0
|
||||
try:
|
||||
with os.scandir(cache_dir) as iterator:
|
||||
for entry in iterator:
|
||||
if not entry.is_file() or not entry.name.endswith('.webp'):
|
||||
continue
|
||||
try:
|
||||
stat = entry.stat()
|
||||
except OSError:
|
||||
continue
|
||||
total_size += stat.st_size
|
||||
entries.append((stat.st_mtime_ns, stat.st_size, entry.path))
|
||||
except OSError:
|
||||
return
|
||||
if total_size <= CARD_THUMBNAIL_CACHE_LIMIT:
|
||||
return
|
||||
target_size = int(CARD_THUMBNAIL_CACHE_LIMIT * 0.8)
|
||||
for _, size, path in sorted(entries):
|
||||
try:
|
||||
os.remove(path)
|
||||
total_size -= size
|
||||
except OSError:
|
||||
pass
|
||||
if total_size <= target_size:
|
||||
break
|
||||
|
||||
|
||||
def _build_card_thumbnail_impl(source_path):
|
||||
"""Return a cached 512px WebP card image, or the original on any safe fallback."""
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
source_stat = os.stat(source_path)
|
||||
cache_key = "\0".join((
|
||||
os.path.realpath(source_path),
|
||||
str(source_stat.st_size),
|
||||
str(source_stat.st_mtime_ns),
|
||||
str(getattr(source_stat, 'st_ctime_ns', 0)),
|
||||
str(CARD_THUMBNAIL_EDGE),
|
||||
))
|
||||
digest = hashlib.sha256(cache_key.encode('utf-8', errors='surrogatepass')).hexdigest()
|
||||
cache_dir = _thumbnail_cache_directory()
|
||||
cached_path = os.path.join(cache_dir, f"{digest}.webp")
|
||||
if os.path.isfile(cached_path):
|
||||
try:
|
||||
os.utime(cached_path, None)
|
||||
except OSError:
|
||||
pass
|
||||
_prune_thumbnail_cache(cache_dir)
|
||||
return cached_path
|
||||
|
||||
with Image.open(source_path) as image:
|
||||
if getattr(image, 'is_animated', False):
|
||||
return source_path
|
||||
image = ImageOps.exif_transpose(image)
|
||||
if max(image.size) <= CARD_THUMBNAIL_EDGE:
|
||||
return source_path
|
||||
resampling = getattr(Image, 'Resampling', Image).LANCZOS
|
||||
image.thumbnail((CARD_THUMBNAIL_EDGE, CARD_THUMBNAIL_EDGE), resampling)
|
||||
if image.mode not in ('RGB', 'RGBA'):
|
||||
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
|
||||
temp_path = (
|
||||
f"{cached_path}.{os.getpid()}.{threading.get_ident()}.tmp"
|
||||
)
|
||||
try:
|
||||
image.save(temp_path, format='WEBP', quality=84, method=4)
|
||||
os.replace(temp_path, cached_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
_prune_thumbnail_cache(cache_dir, created=True)
|
||||
return cached_path
|
||||
except Exception:
|
||||
return source_path
|
||||
|
||||
|
||||
def _build_card_thumbnail(source_path):
|
||||
# Bound simultaneous decodes so opening a large folder cannot monopolize CPU/RAM.
|
||||
with _thumbnail_generation_slots:
|
||||
return _build_card_thumbnail_impl(source_path)
|
||||
|
||||
|
||||
async def api_serve_image(request):
|
||||
"""Dedicated image serving endpoint for model preview images."""
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
subfolder = request.query.get('subfolder', '')
|
||||
filename = request.query.get('filename', '')
|
||||
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
file_path = resolve_within(target_dir, filename)
|
||||
except (ValueError, KeyError):
|
||||
return web.Response(status=400, text='Invalid request')
|
||||
|
||||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||||
return web.Response(status=404, text='Image not found')
|
||||
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
content_types = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
'.avif': 'image/avif',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo'
|
||||
}
|
||||
content_type = content_types.get(ext)
|
||||
if content_type is None:
|
||||
return web.Response(status=415, text='Unsupported media type')
|
||||
|
||||
served_path = file_path
|
||||
if (
|
||||
request.query.get('variant') == 'card'
|
||||
and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS
|
||||
):
|
||||
served_path = await asyncio.to_thread(_build_card_thumbnail, file_path)
|
||||
if served_path != file_path:
|
||||
content_type = 'image/webp'
|
||||
|
||||
headers = {'Content-Type': content_type}
|
||||
if request.query.get('t'):
|
||||
headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
||||
return web.FileResponse(served_path, headers=headers)
|
||||
|
||||
|
||||
async def api_clear_cache(request):
|
||||
try:
|
||||
from .metadata import clear_metadata_cache
|
||||
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
folder_paths.filename_list_cache.clear()
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
folder_paths.cache_helper.clear()
|
||||
clear_metadata_cache()
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
def read_png_text_fast(path):
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
signature = f.read(8)
|
||||
if signature != b'\x89PNG\r\n\x1a\n':
|
||||
return None
|
||||
while True:
|
||||
length_bytes = f.read(4)
|
||||
if not length_bytes: break
|
||||
length = struct.unpack('>I', length_bytes)[0]
|
||||
chunk_type = f.read(4)
|
||||
if chunk_type == b'tEXt':
|
||||
data = f.read(length)
|
||||
keyword, text = data.split(b'\0', 1)
|
||||
if keyword == b'prompt':
|
||||
return text.decode('utf-8', errors='ignore')
|
||||
else:
|
||||
f.seek(length, 1)
|
||||
f.seek(4, 1)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def api_get_model_images(request):
|
||||
model_name = request.rel_url.query.get('model_name', '')
|
||||
if not model_name:
|
||||
return web.json_response({'images': []})
|
||||
|
||||
base_target = os.path.basename(model_name).lower()
|
||||
|
||||
# We will search the output directory
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if not os.path.exists(output_dir):
|
||||
return web.json_response({'images': []})
|
||||
|
||||
def find_images():
|
||||
matched_images = []
|
||||
for root, _, files in os.walk(output_dir):
|
||||
for file in files:
|
||||
if not file.lower().endswith('.png'):
|
||||
continue
|
||||
full_path = os.path.join(root, file)
|
||||
prompt_text = read_png_text_fast(full_path)
|
||||
if not prompt_text:
|
||||
continue
|
||||
try:
|
||||
prompt_data = json.loads(prompt_text)
|
||||
matched = any(
|
||||
os.path.basename(v).lower() == base_target
|
||||
for node in prompt_data.values()
|
||||
if isinstance(node, dict) and 'class_type' in node
|
||||
for v in node.get('inputs', {}).values()
|
||||
if isinstance(v, str)
|
||||
)
|
||||
if not matched:
|
||||
continue
|
||||
rel_path = os.path.relpath(root, output_dir).replace('\\', '/')
|
||||
if rel_path == '.':
|
||||
rel_path = ''
|
||||
url = f'/view?filename={urllib.parse.quote(file)}&type=output'
|
||||
if rel_path:
|
||||
url += f'&subfolder={urllib.parse.quote(rel_path)}'
|
||||
matched_images.append({'url': url, 'mtime': os.path.getmtime(full_path)})
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
||||
pass
|
||||
matched_images.sort(key=lambda x: x['mtime'], reverse=True)
|
||||
return matched_images
|
||||
|
||||
matched_images = await asyncio.to_thread(find_images)
|
||||
|
||||
return web.json_response({'images': matched_images})
|
||||
@@ -0,0 +1,201 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
import copy
|
||||
from functools import lru_cache
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
try:
|
||||
from ..model_identity import sidecar_file_hash
|
||||
except ImportError:
|
||||
from model_identity import sidecar_file_hash
|
||||
|
||||
def _select_info_file(data, file_path):
|
||||
"""Select the Civitai file entry that actually describes ``file_path``.
|
||||
|
||||
A model version may contain a checkpoint, text encoder, and VAE in the same
|
||||
``files`` array. Taking the first SHA256 silently assigns the dependency's
|
||||
hash to every local sidecar copied from that version. Physical byte size is
|
||||
the strongest cheap discriminator available here and remains valid after a
|
||||
local rename. Filenames are intentionally excluded: local renames are the
|
||||
reason provenance recovery exists and cannot be identity evidence.
|
||||
"""
|
||||
entries = []
|
||||
for file_info in data.get("files", []):
|
||||
if not isinstance(file_info, dict):
|
||||
continue
|
||||
hashes = file_info.get("hashes", {})
|
||||
sha256 = hashes.get("SHA256") if isinstance(hashes, dict) else None
|
||||
if not sha256:
|
||||
continue
|
||||
|
||||
size_bytes = None
|
||||
try:
|
||||
if file_info.get("sizeKB") is not None:
|
||||
size_bytes = int(round(float(file_info["sizeKB"]) * 1024))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
pass
|
||||
entries.append((file_info, str(sha256), size_bytes))
|
||||
|
||||
if not entries:
|
||||
return None
|
||||
|
||||
try:
|
||||
physical_size = os.path.getsize(file_path)
|
||||
except OSError:
|
||||
physical_size = None
|
||||
|
||||
if physical_size is not None:
|
||||
size_matches = [entry for entry in entries if entry[2] == physical_size]
|
||||
if len(size_matches) == 1:
|
||||
return size_matches[0][0]
|
||||
if len(size_matches) > 1:
|
||||
return None
|
||||
|
||||
# Offline-generated metadata commonly contains one injected hash without
|
||||
# Civitai size/name data. It is safe only when there is a single candidate.
|
||||
if len(entries) == 1:
|
||||
return entries[0][0]
|
||||
return None
|
||||
|
||||
|
||||
def _select_info_hash(data, file_path):
|
||||
selected = _select_info_file(data, file_path)
|
||||
if not selected:
|
||||
return ""
|
||||
hashes = selected.get("hashes", {})
|
||||
return str(hashes.get("SHA256", "")) if isinstance(hashes, dict) else ""
|
||||
|
||||
|
||||
def _positive_civitai_id(value):
|
||||
"""Return a real Civitai identifier, excluding offline sentinel values."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
def _read_metadata(file_path):
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
metadata = {
|
||||
"name": os.path.basename(base_path),
|
||||
"description": "",
|
||||
"notes": "",
|
||||
"trainedWords": [],
|
||||
"baseModel": "",
|
||||
"civitai_url": "",
|
||||
"source_url": "",
|
||||
"hash": "",
|
||||
"hash_algorithm": "sha256",
|
||||
"hash_scope": "file",
|
||||
"hash_source": "",
|
||||
"custom_name": "",
|
||||
"custom_notes": ""
|
||||
}
|
||||
|
||||
info_files = [f"{base_path}.info", f"{base_path}.civitai.info"]
|
||||
for info_file in info_files:
|
||||
if os.path.exists(info_file):
|
||||
try:
|
||||
with open(info_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
name = data.get("name", "")
|
||||
if "model" in data and isinstance(data["model"], dict):
|
||||
model_name = data["model"].get("name", "")
|
||||
name = f"{model_name} - {name}".strip(' -')
|
||||
|
||||
description = data.get("description", "") or ""
|
||||
if not description and "model" in data and isinstance(data["model"], dict):
|
||||
description = data["model"].get("description", "")
|
||||
notes = data.get("notes", "")
|
||||
trained_words = data.get("trainedWords", [])
|
||||
base_model = data.get("baseModel", "")
|
||||
|
||||
model_id = _positive_civitai_id(data.get("modelId"))
|
||||
version_id = _positive_civitai_id(data.get("id"))
|
||||
civitai_url = ""
|
||||
if model_id is not None:
|
||||
# Handle Civitai's new mature content policy
|
||||
nsfw_level = data.get("nsfwLevel", 1)
|
||||
is_nsfw = False
|
||||
if "model" in data and isinstance(data["model"], dict):
|
||||
is_nsfw = data["model"].get("nsfw", False)
|
||||
|
||||
domain = "civitai.red" if (nsfw_level > 1 or is_nsfw) else "civitai.com"
|
||||
civitai_url = f"https://{domain}/models/{model_id}"
|
||||
if version_id is not None:
|
||||
civitai_url += f"?modelVersionId={version_id}"
|
||||
|
||||
|
||||
selected_file = _select_info_file(data, file_path)
|
||||
hash_val, hash_source = sidecar_file_hash(data, file_path, selected_file)
|
||||
if hash_val:
|
||||
metadata["hash_source"] = hash_source
|
||||
|
||||
if name: metadata["name"] = name
|
||||
if description: metadata["description"] = description
|
||||
if notes: metadata["notes"] = notes
|
||||
if trained_words: metadata["trainedWords"] = trained_words
|
||||
if base_model: metadata["baseModel"] = base_model
|
||||
if civitai_url: metadata["civitai_url"] = civitai_url
|
||||
if model_id is not None: metadata["model_id"] = model_id
|
||||
if version_id is not None: metadata["version_id"] = version_id
|
||||
if hash_val: metadata["hash"] = hash_val
|
||||
if "anomalous_custom_name" in data and data["anomalous_custom_name"]: metadata["custom_name"] = data["anomalous_custom_name"]
|
||||
if "anomalous_custom_notes" in data and data["anomalous_custom_notes"]: metadata["custom_notes"] = data["anomalous_custom_notes"]
|
||||
if "anomalous_source_url" in data and data["anomalous_source_url"]: metadata["source_url"] = data["anomalous_source_url"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def _file_signature(file_path):
|
||||
try:
|
||||
stat = os.stat(file_path)
|
||||
return (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _metadata_signature(file_path):
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
return (
|
||||
_file_signature(file_path),
|
||||
_file_signature(f"{base_path}.info"),
|
||||
_file_signature(f"{base_path}.civitai.info"),
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _get_metadata_cached(file_path, signature):
|
||||
# ``signature`` is deliberately unused by the parser itself; it is part of
|
||||
# the cache key and changes whenever the model or either metadata sidecar
|
||||
# changes. The cache is bounded so removed/renamed models cannot grow it
|
||||
# without limit.
|
||||
return _read_metadata(file_path)
|
||||
|
||||
|
||||
def get_metadata(file_path):
|
||||
normalized_path = os.path.realpath(file_path)
|
||||
signature = _metadata_signature(normalized_path)
|
||||
# Metadata contains mutable lists. Never expose the cached object directly.
|
||||
return copy.deepcopy(_get_metadata_cached(normalized_path, signature))
|
||||
|
||||
|
||||
def clear_metadata_cache():
|
||||
_get_metadata_cached.cache_clear()
|
||||
|
||||
|
||||
def get_metadata_cache_info():
|
||||
return _get_metadata_cached.cache_info()
|
||||
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
"""Model folder discovery, listing, search, and presentation lookup."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .metadata import get_metadata
|
||||
from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES
|
||||
from .model_media import _cache_token, _preview_url_for_model
|
||||
from .folder_types import (
|
||||
get_active_folder_types, get_active_model_roots,
|
||||
get_active_physical_basenames, get_folder_view_mode,
|
||||
)
|
||||
from .path_utils import resolve_folder_subdir
|
||||
|
||||
def _collect_folder_models(target_dir, folder_type, path_idx, rel_subfolder, page, limit):
|
||||
"""Collect one folder in a worker thread with a single directory listing."""
|
||||
try:
|
||||
with os.scandir(target_dir) as iterator:
|
||||
file_entries = {}
|
||||
for entry in iterator:
|
||||
try:
|
||||
if entry.is_file():
|
||||
file_entries[entry.name] = entry
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
return {"models": [], "total": 0, "page": page, "limit": limit}
|
||||
|
||||
valid_files = sorted(
|
||||
(name for name in file_entries if name.endswith(('.safetensors', '.ckpt', '.pt'))),
|
||||
key=str.lower,
|
||||
)
|
||||
total = len(valid_files)
|
||||
if limit > 0:
|
||||
start = max(0, (page - 1) * limit)
|
||||
sliced = valid_files[start:start + limit]
|
||||
else:
|
||||
sliced = valid_files
|
||||
|
||||
q_type = urllib.parse.quote(folder_type)
|
||||
q_idx = str(path_idx)
|
||||
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder and rel_subfolder != '/' else ""
|
||||
models = []
|
||||
for filename in sliced:
|
||||
file_path = os.path.join(target_dir, filename)
|
||||
metadata = get_metadata(file_path)
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
preview_file = next(
|
||||
(base_name + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS if base_name + suffix in file_entries),
|
||||
None,
|
||||
)
|
||||
preview_url = ""
|
||||
if preview_file:
|
||||
q_file = urllib.parse.quote(preview_file)
|
||||
try:
|
||||
preview_version = file_entries[preview_file].stat().st_mtime_ns
|
||||
except OSError:
|
||||
preview_version = 0
|
||||
preview_url = (
|
||||
f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}"
|
||||
f"&filename={q_file}&t={preview_version}"
|
||||
)
|
||||
try:
|
||||
model_stat = file_entries[filename].stat()
|
||||
size_bytes = model_stat.st_size
|
||||
size_mb = round(size_bytes / (1024 * 1024), 2)
|
||||
except OSError:
|
||||
size_mb = 0
|
||||
size_bytes = 0
|
||||
models.append({
|
||||
"filename": filename,
|
||||
"file_path": os.path.abspath(file_path),
|
||||
"size_mb": size_mb,
|
||||
"size_bytes": size_bytes,
|
||||
"metadata": metadata,
|
||||
"preview_url": preview_url,
|
||||
"type": folder_type,
|
||||
"path_idx": path_idx,
|
||||
"subfolder": rel_subfolder,
|
||||
})
|
||||
return {"models": models, "total": total, "page": page, "limit": limit}
|
||||
|
||||
|
||||
def _collect_folders():
|
||||
mode = get_folder_view_mode()
|
||||
result = []
|
||||
seen_dirs = set()
|
||||
|
||||
if mode == "physical":
|
||||
active_bns = get_active_physical_basenames()
|
||||
all_paths_info = []
|
||||
|
||||
for t in folder_paths.folder_names_and_paths.keys():
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
if not paths: continue
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if not os.path.exists(base_dir): continue
|
||||
real_path = os.path.realpath(base_dir)
|
||||
if real_path in seen_dirs: continue
|
||||
seen_dirs.add(real_path)
|
||||
|
||||
bn = os.path.basename(os.path.normpath(base_dir))
|
||||
all_paths_info.append({
|
||||
"t": t,
|
||||
"path_idx": path_idx,
|
||||
"base_dir": base_dir,
|
||||
"bn": bn
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
for target_bn in active_bns:
|
||||
matched = [p for p in all_paths_info if p["bn"] == target_bn]
|
||||
if not matched: continue
|
||||
|
||||
for item in matched:
|
||||
base_dir = item["base_dir"]
|
||||
tree = {}
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
has_models = any(f.endswith(('.safetensors', '.ckpt', '.pt')) for f in files)
|
||||
rel = os.path.relpath(root, base_dir)
|
||||
if rel == '.':
|
||||
rel = '/'
|
||||
else:
|
||||
rel = '/' + rel.replace('\\', '/')
|
||||
tree[rel] = {
|
||||
"path": rel,
|
||||
"name": os.path.basename(root) if rel != '/' else '[Root]',
|
||||
"has_models": has_models,
|
||||
"model_count": sum(1 for f in files if f.endswith(('.safetensors', '.ckpt', '.pt')))
|
||||
}
|
||||
|
||||
label = item["bn"]
|
||||
if len(matched) > 1:
|
||||
label += f" ({item['path_idx'] + 1})"
|
||||
|
||||
result.append({
|
||||
"type": item["t"],
|
||||
"path_idx": item["path_idx"],
|
||||
"label": label,
|
||||
"folders": tree
|
||||
})
|
||||
else:
|
||||
types = get_active_folder_types()
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
except Exception:
|
||||
continue
|
||||
if not paths:
|
||||
continue
|
||||
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if not os.path.exists(base_dir):
|
||||
continue
|
||||
real_path = os.path.realpath(base_dir)
|
||||
if real_path in seen_dirs:
|
||||
continue
|
||||
seen_dirs.add(real_path)
|
||||
|
||||
tree = {}
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
has_models = any(f.endswith(('.safetensors', '.ckpt', '.pt')) for f in files)
|
||||
rel = os.path.relpath(root, base_dir)
|
||||
if rel == '.':
|
||||
rel = '/'
|
||||
else:
|
||||
rel = '/' + rel.replace('\\', '/')
|
||||
tree[rel] = {
|
||||
"path": rel,
|
||||
"name": os.path.basename(root) if rel != '/' else '[Root]',
|
||||
"has_models": has_models,
|
||||
"model_count": sum(1 for f in files if f.endswith(('.safetensors', '.ckpt', '.pt')))
|
||||
}
|
||||
|
||||
try:
|
||||
folder_basename = os.path.basename(os.path.normpath(base_dir))
|
||||
if not folder_basename:
|
||||
folder_basename = t
|
||||
except:
|
||||
folder_basename = t
|
||||
|
||||
label = folder_basename
|
||||
|
||||
basenames = []
|
||||
try:
|
||||
basenames = [os.path.basename(os.path.normpath(p)) for p in paths]
|
||||
except:
|
||||
pass
|
||||
if basenames.count(folder_basename) > 1:
|
||||
label += f" ({path_idx + 1})"
|
||||
|
||||
result.append({
|
||||
"type": t,
|
||||
"path_idx": path_idx,
|
||||
"label": label,
|
||||
"folders": tree
|
||||
})
|
||||
|
||||
return {"folders": result}
|
||||
|
||||
|
||||
async def api_get_folders(request):
|
||||
return web.json_response(await asyncio.to_thread(_collect_folders))
|
||||
|
||||
|
||||
async def api_get_models(request):
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
subfolder = request.query.get('subfolder', '/')
|
||||
page = int(request.query.get('page', 1))
|
||||
limit = int(request.query.get('limit', 0))
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
return web.json_response({"models": [], "total": 0})
|
||||
try:
|
||||
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
except (ValueError, KeyError):
|
||||
return web.Response(status=400, text='Invalid subfolder')
|
||||
rel_subfolder = "" if subfolder == '/' else subfolder.strip('/\\')
|
||||
if not os.path.exists(target_dir):
|
||||
return web.json_response({"models": [], "total": 0})
|
||||
payload = await asyncio.to_thread(
|
||||
_collect_folder_models,
|
||||
target_dir,
|
||||
folder_type,
|
||||
path_idx,
|
||||
rel_subfolder,
|
||||
page,
|
||||
limit,
|
||||
)
|
||||
return web.json_response(payload)
|
||||
|
||||
|
||||
def _iter_search_models():
|
||||
for folder_type in folder_paths.folder_names_and_paths.keys():
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
continue
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if not os.path.isdir(base_dir):
|
||||
continue
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for filename in files:
|
||||
if filename.lower().endswith(MODEL_EXTENSIONS + ('.sft',)):
|
||||
yield folder_type, path_idx, base_dir, os.path.join(root, filename)
|
||||
|
||||
|
||||
def _find_model_sync(search):
|
||||
normalized_search = search.replace('\\', '/').lower()
|
||||
for folder_type, path_idx, base_dir, file_path in _iter_search_models():
|
||||
normalized_path = file_path.replace(os.sep, '/').lower()
|
||||
if normalized_search in os.path.basename(file_path).lower() or normalized_search in normalized_path:
|
||||
return _model_info_for_path(folder_type, path_idx, base_dir, file_path)
|
||||
|
||||
for folder_type, path_idx, base_dir, file_path in _iter_search_models():
|
||||
metadata = get_metadata(file_path)
|
||||
searchable_names = (metadata.get('custom_name', ''), metadata.get('name', ''))
|
||||
if any(normalized_search in str(name).lower() for name in searchable_names if name):
|
||||
return _model_info_for_path(folder_type, path_idx, base_dir, file_path)
|
||||
return None
|
||||
|
||||
|
||||
async def api_find_model(request):
|
||||
search = request.query.get('search', '').strip()
|
||||
if not search:
|
||||
return web.json_response({"status": "error", "message": "No search query provided"})
|
||||
|
||||
result = await asyncio.to_thread(_find_model_sync, search)
|
||||
if not result:
|
||||
return web.json_response({"status": "error", "message": "Model not found"})
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"model": result,
|
||||
"type": result["type"],
|
||||
"path_idx": result["path_idx"],
|
||||
"subfolder": result["subfolder"],
|
||||
})
|
||||
|
||||
|
||||
# Prompt Notes ask for this base model to list models whose metadata names none.
|
||||
UNLABELED_BASE_MODEL = "__unlabeled__"
|
||||
|
||||
|
||||
def _normalised_base(value):
|
||||
return str(value or "").strip().lower().replace(" ", "")
|
||||
|
||||
|
||||
def _walk_model_files(target_types):
|
||||
"""Yield (folder_type, path_idx, base_dir, root, filename, metadata) once per real file."""
|
||||
seen_files = set()
|
||||
for folder_type in target_types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
continue
|
||||
for path_idx, base_dir in enumerate(paths or []):
|
||||
if not os.path.exists(base_dir):
|
||||
continue
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for filename in files:
|
||||
if not filename.lower().endswith(MODEL_EXTENSIONS):
|
||||
continue
|
||||
file_path = os.path.join(root, filename)
|
||||
real_path = os.path.realpath(file_path)
|
||||
if real_path in seen_files:
|
||||
continue
|
||||
seen_files.add(real_path)
|
||||
yield folder_type, path_idx, base_dir, root, filename, get_metadata(file_path)
|
||||
|
||||
|
||||
def _base_matches(requested, metadata):
|
||||
model_base = _normalised_base(metadata.get("baseModel"))
|
||||
if requested == UNLABELED_BASE_MODEL:
|
||||
return not model_base
|
||||
wanted = _normalised_base(requested)
|
||||
return bool(wanted and model_base and (wanted in model_base or model_base in wanted))
|
||||
|
||||
|
||||
def _compatible_models_sync(base_model, target_types):
|
||||
compatible_models = []
|
||||
for folder_type, path_idx, base_dir, root, filename, meta in _walk_model_files(target_types):
|
||||
if not _base_matches(base_model, meta):
|
||||
continue
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
rel_subfolder = '/' if rel_subfolder == '.' else '/' + rel_subfolder.replace('\\', '/')
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
preview_file = None
|
||||
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
|
||||
if os.path.exists(os.path.join(root, base_name + ext)):
|
||||
preview_file = base_name + ext
|
||||
break
|
||||
preview_url = ""
|
||||
if preview_file:
|
||||
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder != '/' else ""
|
||||
preview_url = (
|
||||
f"/anomalous/image?type={urllib.parse.quote(folder_type)}&path_idx={path_idx}"
|
||||
f"&subfolder={q_sub}&filename={urllib.parse.quote(preview_file)}"
|
||||
)
|
||||
try:
|
||||
size_bytes = os.path.getsize(os.path.join(root, filename))
|
||||
except OSError:
|
||||
size_bytes = 0
|
||||
compatible_models.append({
|
||||
"type": folder_type,
|
||||
"path_idx": path_idx,
|
||||
"subfolder": rel_subfolder,
|
||||
"filename": filename,
|
||||
"size_mb": round(size_bytes / (1024 * 1024), 1),
|
||||
"size_bytes": size_bytes,
|
||||
"preview_url": preview_url,
|
||||
"metadata": meta,
|
||||
})
|
||||
return compatible_models
|
||||
|
||||
|
||||
async def api_compatible_models(request):
|
||||
base_model = request.query.get('base_model', '')
|
||||
if not base_model:
|
||||
return web.json_response({"models": []})
|
||||
target_types = [t.strip() for t in request.query.get('target_type', 'loras').split(',') if t.strip()]
|
||||
models = await asyncio.to_thread(_compatible_models_sync, base_model, target_types)
|
||||
return web.json_response({"models": models})
|
||||
|
||||
|
||||
def _base_models_sync(target_types):
|
||||
base_models = set()
|
||||
unlabeled = False
|
||||
for *_, meta in _walk_model_files(target_types):
|
||||
label = str(meta.get("baseModel") or "").strip()
|
||||
if label:
|
||||
base_models.add(label)
|
||||
else:
|
||||
unlabeled = True
|
||||
return sorted(base_models), unlabeled
|
||||
|
||||
|
||||
async def api_base_models(request):
|
||||
base_models, unlabeled = await asyncio.to_thread(_base_models_sync, get_active_folder_types())
|
||||
return web.json_response({"base_models": base_models, "unlabeled": unlabeled})
|
||||
|
||||
|
||||
def _model_info_for_path(folder_type, path_idx, base_dir, file_path):
|
||||
root = os.path.dirname(file_path)
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.':
|
||||
rel_subfolder = '/'
|
||||
else:
|
||||
rel_subfolder = '/' + rel_subfolder.replace(os.sep, '/')
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
except OSError:
|
||||
size_bytes = 0
|
||||
return {
|
||||
"type": folder_type,
|
||||
"path_idx": path_idx,
|
||||
"subfolder": rel_subfolder,
|
||||
"filename": os.path.basename(file_path),
|
||||
"file_path": os.path.abspath(file_path),
|
||||
"size_bytes": size_bytes,
|
||||
"size_mb": round(size_bytes / (1024 * 1024), 2),
|
||||
"preview_url": _preview_url_for_model(folder_type, path_idx, base_dir, file_path),
|
||||
"metadata": get_metadata(file_path),
|
||||
}
|
||||
|
||||
|
||||
def _allowed_folder_types(requested_types=None):
|
||||
available = list(folder_paths.folder_names_and_paths.keys())
|
||||
if requested_types is None:
|
||||
return available
|
||||
if not isinstance(requested_types, list):
|
||||
return []
|
||||
allowed = set(available)
|
||||
return list(dict.fromkeys(
|
||||
folder_type
|
||||
for folder_type in requested_types
|
||||
if isinstance(folder_type, str) and folder_type in allowed
|
||||
))
|
||||
|
||||
|
||||
def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False):
|
||||
requested = [
|
||||
(path, path.replace('\\', '/').lower(), path.replace('\\', '/'))
|
||||
for path in paths
|
||||
if isinstance(path, str)
|
||||
]
|
||||
exact_results = {}
|
||||
roots = []
|
||||
for folder_type in _allowed_folder_types(folder_types):
|
||||
try:
|
||||
folder_dirs = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
continue
|
||||
for path_idx, base_dir in enumerate(folder_dirs or []):
|
||||
if not os.path.isdir(base_dir):
|
||||
continue
|
||||
real_base_dir = os.path.realpath(base_dir)
|
||||
roots.append((folder_type, path_idx, real_base_dir))
|
||||
for original, _, relative_path in requested:
|
||||
relative = relative_path.replace('/', os.sep)
|
||||
candidate = os.path.realpath(os.path.join(real_base_dir, relative))
|
||||
try:
|
||||
if os.path.commonpath((real_base_dir, candidate)) != real_base_dir:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
if os.path.isfile(candidate) and candidate.lower().endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
|
||||
exact_results[original] = _model_info_for_path(folder_type, path_idx, base_dir, candidate)
|
||||
|
||||
unresolved = [(original, normalized) for original, normalized, _ in requested if original not in exact_results]
|
||||
if exact_only or not unresolved:
|
||||
return exact_results
|
||||
|
||||
wanted_relpaths = {normalized for _, normalized in unresolved}
|
||||
wanted_basenames = {normalized.rsplit('/', 1)[-1] for _, normalized in unresolved}
|
||||
rel_matches = {}
|
||||
basename_matches = {}
|
||||
for folder_type, path_idx, base_dir in roots:
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for filename in files:
|
||||
if not filename.lower().endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
|
||||
continue
|
||||
rel_path = os.path.relpath(os.path.join(root, filename), base_dir).replace(os.sep, '/').lower()
|
||||
basename = filename.lower()
|
||||
if rel_path not in wanted_relpaths and basename not in wanted_basenames:
|
||||
continue
|
||||
candidate_info = _model_info_for_path(
|
||||
folder_type,
|
||||
path_idx,
|
||||
base_dir,
|
||||
os.path.join(root, filename),
|
||||
)
|
||||
if rel_path in wanted_relpaths:
|
||||
rel_matches[rel_path] = candidate_info
|
||||
if basename in wanted_basenames:
|
||||
basename_matches[basename] = candidate_info
|
||||
|
||||
model_info = dict(exact_results)
|
||||
for original, normalized in unresolved:
|
||||
if normalized in rel_matches:
|
||||
model_info[original] = rel_matches[normalized]
|
||||
else:
|
||||
basename = normalized.rsplit('/', 1)[-1]
|
||||
if basename in basename_matches:
|
||||
model_info[original] = basename_matches[basename]
|
||||
return model_info
|
||||
|
||||
|
||||
def _resolve_paths_to_previews_sync(paths, folder_types=None, exact_only=False):
|
||||
model_info = _resolve_paths_to_model_info_sync(paths, folder_types, exact_only)
|
||||
return {path: item.get("preview_url", "") for path, item in model_info.items()}
|
||||
|
||||
|
||||
async def api_resolve_paths_to_previews(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
paths = data.get('paths', [])
|
||||
folder_types = data.get('folder_types')
|
||||
context_requests = data.get('context_requests', [])
|
||||
exact_only = data.get('exact_only') is True
|
||||
except:
|
||||
return web.json_response({"previews": {}, "models": {}, "context_models": {}})
|
||||
|
||||
model_info = await asyncio.to_thread(_resolve_paths_to_model_info_sync, paths, folder_types, exact_only)
|
||||
context_models = {}
|
||||
if isinstance(context_requests, list):
|
||||
for item in context_requests[:16]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
context_path = item.get('path')
|
||||
if not isinstance(context_path, str) or not context_path:
|
||||
continue
|
||||
resolved = await asyncio.to_thread(
|
||||
_resolve_paths_to_model_info_sync,
|
||||
[context_path],
|
||||
item.get('folder_types'),
|
||||
item.get('exact_only') is True or exact_only,
|
||||
)
|
||||
if context_path in resolved:
|
||||
context_key = item.get('key')
|
||||
if not isinstance(context_key, str) or not context_key:
|
||||
context_key = context_path
|
||||
context_models[context_key] = resolved[context_path]
|
||||
|
||||
previews = {path: item.get("preview_url", "") for path, item in model_info.items()}
|
||||
return web.json_response({
|
||||
"previews": previews,
|
||||
"models": model_info,
|
||||
"context_models": context_models,
|
||||
})
|
||||
|
||||
|
||||
def _collect_all_scan_models(page, limit):
|
||||
source_library_extensions = {'.safetensors', '.ckpt', '.pt', '.pth', '.bin', '.sft', '.gguf'}
|
||||
all_tuples = []
|
||||
for model_root in get_active_model_roots():
|
||||
t = model_root["type"]
|
||||
path_idx = model_root["path_idx"]
|
||||
base_dir = model_root["base_dir"]
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for f in files:
|
||||
if os.path.splitext(f)[1].lower() in source_library_extensions:
|
||||
all_tuples.append((t, path_idx, root, base_dir, f))
|
||||
|
||||
all_tuples.sort(key=lambda x: (x[0], x[4].lower()))
|
||||
total = len(all_tuples)
|
||||
|
||||
if limit > 0:
|
||||
start = (page - 1) * limit
|
||||
end = start + limit
|
||||
sliced = all_tuples[start:end]
|
||||
else:
|
||||
sliced = all_tuples
|
||||
|
||||
all_models = []
|
||||
for t, path_idx, root, base_dir, f in sliced:
|
||||
file_path = os.path.join(root, f)
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.': rel_subfolder = ''
|
||||
else: rel_subfolder = rel_subfolder.replace('\\', '/')
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
size_mb = round(size_bytes / (1024 * 1024), 2)
|
||||
except:
|
||||
size_bytes = 0; size_mb = 0
|
||||
meta = get_metadata(file_path)
|
||||
base_name = os.path.splitext(f)[0]
|
||||
preview_file = None
|
||||
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
|
||||
if os.path.exists(os.path.join(root, base_name + ext)):
|
||||
preview_file = base_name + ext
|
||||
break
|
||||
preview_url = ""
|
||||
if preview_file:
|
||||
q_type = urllib.parse.quote(t)
|
||||
q_idx = str(path_idx)
|
||||
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder and rel_subfolder != '/' else ""
|
||||
q_file = urllib.parse.quote(preview_file)
|
||||
mtime = _cache_token(os.path.join(root, preview_file))
|
||||
preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={mtime}"
|
||||
all_models.append({
|
||||
"type": t, "path_idx": path_idx, "subfolder": rel_subfolder,
|
||||
"filename": f, "size_mb": size_mb, "size_bytes": size_bytes,
|
||||
"preview_url": preview_url, "metadata": meta
|
||||
})
|
||||
return {"models": all_models, "total": total, "page": page, "limit": limit}
|
||||
|
||||
|
||||
async def api_get_all_scan_models(request):
|
||||
page = int(request.query.get('page', 1))
|
||||
limit = int(request.query.get('limit', 0))
|
||||
payload = await asyncio.to_thread(_collect_all_scan_models, page, limit)
|
||||
return web.json_response(payload)
|
||||
|
||||
|
||||
async def api_batch_select(request):
|
||||
folder_key = request.query.get('folderKey', 'ALL')
|
||||
action = request.query.get('action', 'all')
|
||||
|
||||
def matches_condition(file_path, root, base_name):
|
||||
if action == 'all':
|
||||
return True
|
||||
elif action == 'no_preview':
|
||||
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
|
||||
if os.path.exists(os.path.join(root, base_name + ext)):
|
||||
return False
|
||||
return True
|
||||
elif action == 'no_desc':
|
||||
info_file = file_path + '.info'
|
||||
civitai_info = os.path.join(root, base_name + '.civitai.info')
|
||||
if os.path.exists(civitai_info):
|
||||
try:
|
||||
with open(civitai_info, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if data.get('description', '').strip():
|
||||
return False
|
||||
except: pass
|
||||
if os.path.exists(info_file):
|
||||
try:
|
||||
with open(info_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if data.get('description', '').strip():
|
||||
return False
|
||||
except: pass
|
||||
return True
|
||||
return False
|
||||
|
||||
results = {}
|
||||
|
||||
if folder_key == 'ALL':
|
||||
target_types = get_active_folder_types()
|
||||
seen_dirs = set()
|
||||
for t in target_types:
|
||||
try: paths = folder_paths.get_folder_paths(t)
|
||||
except Exception: continue
|
||||
if not paths: continue
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if base_dir in seen_dirs: continue
|
||||
seen_dirs.add(base_dir)
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for f in files:
|
||||
if f.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
|
||||
file_path = os.path.join(root, f)
|
||||
base_name = os.path.splitext(f)[0]
|
||||
if matches_condition(file_path, root, base_name):
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.': rel_subfolder = ''
|
||||
else: rel_subfolder = rel_subfolder.replace('\\', '/')
|
||||
fkey = f"{t}|{path_idx}|{rel_subfolder}"
|
||||
if fkey not in results: results[fkey] = []
|
||||
results[fkey].append(f)
|
||||
else:
|
||||
parts = folder_key.split('|')
|
||||
if len(parts) >= 3:
|
||||
t = parts[0]
|
||||
path_idx = int(parts[1])
|
||||
subfolder = parts[2]
|
||||
|
||||
try: paths = folder_paths.get_folder_paths(t)
|
||||
except Exception: paths = []
|
||||
|
||||
if paths and path_idx < len(paths):
|
||||
try:
|
||||
base_dir, target_dir = resolve_folder_subdir(t, path_idx, subfolder)
|
||||
except ValueError:
|
||||
return web.json_response({"selected": {}})
|
||||
|
||||
if os.path.exists(target_dir):
|
||||
try: entries = os.listdir(target_dir)
|
||||
except: entries = []
|
||||
for f in entries:
|
||||
if f.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
|
||||
file_path = os.path.join(target_dir, f)
|
||||
if os.path.isfile(file_path):
|
||||
base_name = os.path.splitext(f)[0]
|
||||
if matches_condition(file_path, target_dir, base_name):
|
||||
if folder_key not in results: results[folder_key] = []
|
||||
results[folder_key].append(f)
|
||||
|
||||
return web.json_response({"selected": results})
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Shared model catalog, media, and recovery constants."""
|
||||
|
||||
MEDIA_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi')
|
||||
MODEL_EXTENSIONS = ('.safetensors', '.ckpt', '.pt', '.bin')
|
||||
PREVIEW_SUFFIXES = tuple(f'.preview{ext}' for ext in MEDIA_EXTENSIONS)
|
||||
CIVITAI_BACKUP_SUFFIXES = tuple(f'.civitai_bak{ext}' for ext in MEDIA_EXTENSIONS)
|
||||
SIDECAR_SUFFIXES = (
|
||||
'.info', '.civitai.info', '.json', '.txt', '.yaml',
|
||||
*MEDIA_EXTENSIONS,
|
||||
*PREVIEW_SUFFIXES,
|
||||
*CIVITAI_BACKUP_SUFFIXES,
|
||||
)
|
||||
RESOLVABLE_MODEL_TYPES = (
|
||||
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet',
|
||||
'vae', 'vae_approx', 'clip', 'text_encoders', 'clip_vision',
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Model preview lookup and bounded custom-cover operations."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
|
||||
from .utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
|
||||
def _cache_token(file_path):
|
||||
try:
|
||||
return os.stat(file_path).st_mtime_ns
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
async def _handle_custom_cover(target_dir, filename, save_func, source_ext='.png'):
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
|
||||
# Always save custom covers with a .preview.[ext] suffix so standard nodes recognize them as covers.
|
||||
if source_ext.startswith('.preview.'):
|
||||
preview_ext = source_ext
|
||||
else:
|
||||
preview_ext = f".preview{source_ext}"
|
||||
|
||||
dest_path = os.path.join(target_dir, f"{base_name}{preview_ext}")
|
||||
|
||||
# Delete any existing .preview.* files to ensure only one custom cover is active
|
||||
for ext in PREVIEW_SUFFIXES:
|
||||
p = os.path.join(target_dir, f"{base_name}{ext}")
|
||||
if os.path.exists(p) and p != dest_path:
|
||||
try: os.remove(p)
|
||||
except: pass
|
||||
|
||||
await save_func(dest_path)
|
||||
|
||||
|
||||
async def api_set_custom_cover(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
folder_type = data.get('type', 'checkpoints')
|
||||
subfolder = data.get('subfolder', '/')
|
||||
filename = data.get('filename', '')
|
||||
source_image = data.get('source_image', '')
|
||||
try: path_idx = int(data.get('path_idx', 0))
|
||||
except: path_idx = 0
|
||||
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
src_path = resolve_within(output_dir, source_image)
|
||||
except (ValueError, KeyError):
|
||||
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
|
||||
if not os.path.exists(src_path):
|
||||
return web.json_response({"status": "error", "message": "Source image not found in output directory"})
|
||||
|
||||
source_ext = os.path.splitext(src_path)[1].lower()
|
||||
if source_ext not in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi'}:
|
||||
return web.json_response({"status": "error", "message": "Unsupported cover format"}, status=415)
|
||||
|
||||
async def save_copy(dest_path):
|
||||
import shutil
|
||||
import asyncio
|
||||
await asyncio.to_thread(shutil.copy2, src_path, dest_path)
|
||||
|
||||
await _handle_custom_cover(target_dir, filename, save_copy, source_ext)
|
||||
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
async def api_upload_custom_cover(request):
|
||||
try:
|
||||
data = await request.post()
|
||||
folder_type = data.get('type', 'checkpoints')
|
||||
subfolder = data.get('subfolder', '/')
|
||||
filename = data.get('filename', '')
|
||||
try: path_idx = int(data.get('path_idx', 0))
|
||||
except: path_idx = 0
|
||||
|
||||
image_field = data.get('image')
|
||||
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
except (ValueError, KeyError):
|
||||
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
|
||||
if image_field is None:
|
||||
return web.json_response({"status": "error", "message": "Image is required"}, status=400)
|
||||
|
||||
image_data = image_field.file.read()
|
||||
if len(image_data) > 100 * 1024 * 1024:
|
||||
return web.json_response({"status": "error", "message": "Cover file is too large"}, status=413)
|
||||
|
||||
upload_filename = image_field.filename
|
||||
source_ext = os.path.splitext(upload_filename)[1].lower()
|
||||
if source_ext not in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi'}:
|
||||
return web.json_response({"status": "error", "message": "Unsupported cover format"}, status=415)
|
||||
|
||||
async def save_upload(dest_path):
|
||||
def write_file():
|
||||
with open(dest_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
import asyncio
|
||||
await asyncio.to_thread(write_file)
|
||||
|
||||
await _handle_custom_cover(target_dir, filename, save_upload, source_ext)
|
||||
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
def _preview_url_for_model(folder_type, path_idx, base_dir, file_path):
|
||||
root = os.path.dirname(file_path)
|
||||
filename = os.path.basename(file_path)
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
preview_file = next(
|
||||
(
|
||||
base_name + suffix
|
||||
for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
|
||||
if os.path.isfile(os.path.join(root, base_name + suffix))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not preview_file:
|
||||
return ""
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.':
|
||||
rel_subfolder = '/'
|
||||
q_type = urllib.parse.quote(folder_type)
|
||||
q_idx = str(path_idx)
|
||||
q_sub = urllib.parse.quote(rel_subfolder)
|
||||
q_file = urllib.parse.quote(preview_file)
|
||||
version = _cache_token(os.path.join(root, preview_file))
|
||||
return f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={version}"
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Model metadata, physical rename, deletion, and sidecar lifecycle."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ..model_policies import is_physical_rename_protected
|
||||
except ImportError:
|
||||
from model_policies import is_physical_rename_protected
|
||||
from .model_constants import (
|
||||
CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, MODEL_EXTENSIONS,
|
||||
PREVIEW_SUFFIXES, SIDECAR_SUFFIXES,
|
||||
)
|
||||
from .model_catalog import _resolve_paths_to_model_info_sync
|
||||
from .utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
|
||||
def _first_existing_sidecar(base_path, suffixes):
|
||||
for suffix in suffixes:
|
||||
candidate = f"{base_path}{suffix}"
|
||||
if os.path.isfile(candidate):
|
||||
return candidate, suffix
|
||||
return None, None
|
||||
|
||||
|
||||
def _reset_model_cover(base_path):
|
||||
"""Restore a recoverable cover without destroying the only existing image."""
|
||||
backup_path, backup_suffix = _first_existing_sidecar(base_path, CIVITAI_BACKUP_SUFFIXES)
|
||||
original_path, _ = _first_existing_sidecar(base_path, MEDIA_EXTENSIONS)
|
||||
preview_paths = [
|
||||
f"{base_path}{suffix}"
|
||||
for suffix in PREVIEW_SUFFIXES
|
||||
if os.path.isfile(f"{base_path}{suffix}")
|
||||
]
|
||||
|
||||
if backup_path:
|
||||
media_ext = backup_suffix[len('.civitai_bak'):]
|
||||
restored_path = f"{base_path}.preview{media_ext}"
|
||||
temp_path = f"{restored_path}.anomalous_tmp"
|
||||
try:
|
||||
# Copy first. If the backup cannot be read, the active custom cover
|
||||
# remains untouched. os.replace then makes the actual restore atomic.
|
||||
shutil.copy2(backup_path, temp_path)
|
||||
os.replace(temp_path, restored_path)
|
||||
for preview_path in preview_paths:
|
||||
if preview_path != restored_path and os.path.isfile(preview_path):
|
||||
os.remove(preview_path)
|
||||
except Exception as exc:
|
||||
try:
|
||||
if os.path.isfile(temp_path):
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
return False, 'restore_failed', str(exc)
|
||||
return True, 'civitai_backup', None
|
||||
|
||||
if original_path:
|
||||
try:
|
||||
for preview_path in preview_paths:
|
||||
os.remove(preview_path)
|
||||
except Exception as exc:
|
||||
return False, 'restore_failed', str(exc)
|
||||
return True, 'original_cover', None
|
||||
|
||||
if preview_paths:
|
||||
# There is no recoverable source. Keep the current cover instead of
|
||||
# turning a harmless Reset click into irreversible image loss.
|
||||
return False, 'preserved_current', 'No Civitai backup or original cover exists.'
|
||||
|
||||
return True, 'no_cover', None
|
||||
|
||||
|
||||
async def api_delete_model(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
data = None
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
|
||||
try:
|
||||
folder_type = data.get('type', 'checkpoints')
|
||||
subfolder = data.get('subfolder', '/')
|
||||
filename = data.get('filename', '')
|
||||
try:
|
||||
path_idx = int(data.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
model_path = resolve_within(target_dir, filename)
|
||||
except (ValueError, KeyError):
|
||||
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
|
||||
if not os.path.exists(model_path):
|
||||
return web.json_response({"status": "error", "message": "Model file not found"})
|
||||
|
||||
# 1. 优先尝试删除你点击的主模型文件
|
||||
try:
|
||||
os.remove(model_path)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "being used" in error_msg or "WinError 32" in error_msg or "Permission" in error_msg:
|
||||
error_msg = "文件被占用 (正在被 ComfyUI 使用)。请先重启 ComfyUI 或在工作流中卸载该模型后再删除!"
|
||||
return web.json_response({"status": "error", "message": f"主模型删除失败: {error_msg}"})
|
||||
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
|
||||
# 2. 主模型成功删除后,再清理配套的垃圾文件
|
||||
# Sidecars are keyed by stem, not by the main model extension. If a
|
||||
# second real model shares this stem, preserve the shared sidecars for
|
||||
# the survivor instead of treating that model as cleanup debris.
|
||||
shared_stem_in_use = any(
|
||||
os.path.isfile(os.path.join(target_dir, base_name + model_ext))
|
||||
for model_ext in MODEL_EXTENSIONS
|
||||
)
|
||||
deleted_files = [filename]
|
||||
if not shared_stem_in_use:
|
||||
for suffix in SIDECAR_SUFFIXES:
|
||||
file_to_del = os.path.join(target_dir, base_name + suffix)
|
||||
if os.path.isfile(file_to_del):
|
||||
try:
|
||||
os.remove(file_to_del)
|
||||
deleted_files.append(base_name + suffix)
|
||||
except Exception as e:
|
||||
print(f"[Anomalous Browser] Warning: Failed to delete {file_to_del}: {e}")
|
||||
|
||||
# 3. 修正:前端期待的成功状态是 "success" 而不是 "ok"
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"deleted": deleted_files,
|
||||
"sidecars_preserved": shared_stem_in_use,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
async def api_update_metadata(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
folder_type = data.get('type', 'checkpoints')
|
||||
subfolder = data.get('subfolder', '/')
|
||||
raw_filename = data.get('filename', '')
|
||||
custom_name = data.get('custom_name', '')
|
||||
custom_notes = data.get('custom_notes', '')
|
||||
custom_source_url = data.get('custom_source_url', None)
|
||||
physical_rename_requested = data.get('physical_rename', False)
|
||||
try: path_idx = int(data.get('path_idx', 0))
|
||||
except: path_idx = 0
|
||||
|
||||
# Normalize filename and subfolder if directory separators are included
|
||||
extracted_filename = raw_filename
|
||||
if isinstance(raw_filename, str) and ('/' in raw_filename or '\\' in raw_filename):
|
||||
if subfolder in (None, '', '/'):
|
||||
extracted_sub = os.path.dirname(raw_filename).replace('\\', '/')
|
||||
if extracted_sub:
|
||||
subfolder = '/' + extracted_sub.strip('/')
|
||||
extracted_filename = os.path.basename(raw_filename)
|
||||
|
||||
file_path = None
|
||||
target_dir = None
|
||||
filename = extracted_filename
|
||||
try:
|
||||
filename = require_filename(extracted_filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
file_path = resolve_within(target_dir, filename)
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
# Fallback: attempt to locate model across known model directories
|
||||
search_keys = [k for k in [raw_filename, extracted_filename] if k and isinstance(k, str)]
|
||||
fallback_resolved = _resolve_paths_to_model_info_sync(search_keys)
|
||||
resolved_info = fallback_resolved.get(raw_filename) or fallback_resolved.get(extracted_filename)
|
||||
if resolved_info:
|
||||
resolved_path = resolved_info.get("file_path")
|
||||
if resolved_path and os.path.exists(resolved_path):
|
||||
file_path = resolved_path
|
||||
target_dir = os.path.dirname(file_path)
|
||||
folder_type = resolved_info.get("type", folder_type)
|
||||
path_idx = resolved_info.get("path_idx", path_idx)
|
||||
filename = resolved_info.get("filename", filename)
|
||||
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return web.json_response({"status": "error", "message": "Model not found"})
|
||||
|
||||
physical_rename_skipped = physical_rename_requested and is_physical_rename_protected(
|
||||
folder_type=folder_type,
|
||||
folder_path=target_dir,
|
||||
)
|
||||
physical_rename = physical_rename_requested and not physical_rename_skipped
|
||||
|
||||
base_name = os.path.splitext(file_path)[0]
|
||||
model_ext = os.path.splitext(file_path)[1]
|
||||
info_file = f"{base_name}.civitai.info"
|
||||
|
||||
info_data = {}
|
||||
if os.path.exists(info_file):
|
||||
parsed = False
|
||||
for enc in ['utf-8', 'utf-8-sig', 'mbcs', 'latin-1']:
|
||||
try:
|
||||
with open(info_file, 'r', encoding=enc) as f:
|
||||
info_data = json.load(f)
|
||||
parsed = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not parsed:
|
||||
return web.json_response({"status": "error", "message": "Failed to parse existing .civitai.info file due to encoding or corruption. Rename aborted to prevent data loss."})
|
||||
|
||||
if 'custom_name' in data:
|
||||
info_data["anomalous_custom_name"] = custom_name
|
||||
if 'custom_notes' in data:
|
||||
info_data["anomalous_custom_notes"] = custom_notes
|
||||
if custom_source_url is not None:
|
||||
info_data["anomalous_source_url"] = str(custom_source_url).strip()
|
||||
|
||||
reset_cover = data.get('reset_cover', False)
|
||||
cover_reset = None
|
||||
cover_reset_source = None
|
||||
cover_reset_warning = None
|
||||
if reset_cover:
|
||||
cover_reset, cover_reset_source, cover_reset_warning = _reset_model_cover(base_name)
|
||||
|
||||
with open(info_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(info_data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
new_filename = filename
|
||||
|
||||
if physical_rename and custom_name:
|
||||
import re
|
||||
safe_name = re.sub(r'[<>:"/\\|?*]', '_', custom_name).strip(' .')
|
||||
if not safe_name:
|
||||
return web.json_response({"status": "error", "message": "The physical filename cannot be empty."}, status=400)
|
||||
new_file_path = os.path.join(target_dir, f"{safe_name}{model_ext}")
|
||||
|
||||
if new_file_path != file_path and not os.path.exists(new_file_path):
|
||||
os.rename(file_path, new_file_path)
|
||||
|
||||
for suffix in SIDECAR_SUFFIXES:
|
||||
old_sidecar = f"{base_name}{suffix}"
|
||||
if os.path.isfile(old_sidecar):
|
||||
os.rename(old_sidecar, os.path.join(target_dir, f"{safe_name}{suffix}"))
|
||||
|
||||
new_filename = f"{safe_name}{model_ext}"
|
||||
elif os.path.exists(new_file_path) and new_file_path != file_path:
|
||||
return web.json_response({"status": "error", "message": "A file with the target physical name already exists."})
|
||||
|
||||
response_data = {
|
||||
"status": "success",
|
||||
"new_filename": new_filename,
|
||||
"physical_rename_skipped": physical_rename_skipped,
|
||||
}
|
||||
if reset_cover:
|
||||
response_data.update({
|
||||
"cover_reset": cover_reset,
|
||||
"cover_reset_source": cover_reset_source,
|
||||
"cover_reset_warning": cover_reset_warning,
|
||||
})
|
||||
return web.json_response(response_data)
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Cryptographic and size-based missing-model resolution."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import threading
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
try:
|
||||
from ..model_identity import computed_file_identity, normalise_sha256
|
||||
except ImportError:
|
||||
from model_identity import computed_file_identity, normalise_sha256
|
||||
try:
|
||||
from ..model_policies import requires_hash_for_model_recovery
|
||||
except ImportError:
|
||||
from model_policies import requires_hash_for_model_recovery
|
||||
from .metadata import get_metadata
|
||||
from .model_constants import MODEL_EXTENSIONS, RESOLVABLE_MODEL_TYPES
|
||||
from .utils import atomic_write_json
|
||||
|
||||
def _parse_resolution_types(expected_types_raw):
|
||||
if not expected_types_raw:
|
||||
return RESOLVABLE_MODEL_TYPES
|
||||
requested = tuple(dict.fromkeys(
|
||||
value.strip() for value in str(expected_types_raw).split(',') if value.strip()
|
||||
))
|
||||
if not requested or any(value not in RESOLVABLE_MODEL_TYPES for value in requested):
|
||||
raise ValueError("Invalid model type")
|
||||
return requested
|
||||
|
||||
|
||||
def _collect_resolution_candidates(types):
|
||||
candidates = []
|
||||
seen_realpaths = set()
|
||||
for folder_type in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
continue
|
||||
for base_dir in paths or []:
|
||||
if not os.path.exists(base_dir):
|
||||
continue
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for filename in files:
|
||||
if not filename.lower().endswith(MODEL_EXTENSIONS):
|
||||
continue
|
||||
file_path = os.path.join(root, filename)
|
||||
real_path = os.path.realpath(file_path)
|
||||
if real_path in seen_realpaths:
|
||||
continue
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
except OSError:
|
||||
continue
|
||||
seen_realpaths.add(real_path)
|
||||
candidates.append({
|
||||
"type": folder_type,
|
||||
"filename": os.path.relpath(file_path, base_dir).replace('\\', '/'),
|
||||
"path": file_path,
|
||||
"size": file_size,
|
||||
})
|
||||
return candidates
|
||||
|
||||
|
||||
def _candidate_hashes(candidate):
|
||||
if "hashes" in candidate:
|
||||
return candidate["hashes"]
|
||||
values = set()
|
||||
metadata = candidate.get("metadata")
|
||||
if metadata is None:
|
||||
metadata = get_metadata(candidate["path"])
|
||||
candidate["metadata"] = metadata
|
||||
meta_hash = normalise_sha256(metadata.get("hash", ""))
|
||||
if meta_hash:
|
||||
values.add(str(meta_hash).upper())
|
||||
candidate["hashes"] = values
|
||||
return values
|
||||
|
||||
|
||||
def _resolved_payload(candidate, **details):
|
||||
payload = {
|
||||
"found": True,
|
||||
"type": candidate["type"],
|
||||
"filename": candidate["filename"],
|
||||
}
|
||||
payload.update(details)
|
||||
return payload
|
||||
|
||||
|
||||
def _size_candidate_payload(candidate, require_hash=False):
|
||||
"""Expose one in-category size match for explicit user confirmation only."""
|
||||
return {
|
||||
"found": False,
|
||||
"confirmation_required": True,
|
||||
"matched_by_size": True,
|
||||
"hash_required": bool(require_hash),
|
||||
"type": candidate["type"],
|
||||
"filename": candidate["filename"],
|
||||
"size": candidate["size"],
|
||||
}
|
||||
|
||||
|
||||
def _compute_and_save_fallback_info(file_path, file_hash):
|
||||
try:
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
info_path = base_path + ".info"
|
||||
civitai_info_path = base_path + ".civitai.info"
|
||||
if os.path.exists(info_path) or os.path.exists(civitai_info_path):
|
||||
info_path = info_path if os.path.exists(info_path) else civitai_info_path
|
||||
with open(info_path, encoding='utf-8') as source:
|
||||
info_data = json.load(source)
|
||||
else:
|
||||
from scraper import infer_base_model_from_header
|
||||
filename = os.path.basename(file_path)
|
||||
inferred_base = infer_base_model_from_header(file_path) if file_path.lower().endswith('.safetensors') else ""
|
||||
if inferred_base == 'Unknown':
|
||||
inferred_base = ""
|
||||
info_data = {
|
||||
"id": -1,
|
||||
"modelId": -1,
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"baseModel": inferred_base,
|
||||
"description": "<p>Automatically inferred by Anomalous Local Engine.</p>",
|
||||
"model": {
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"type": "LORA" if "lora" in file_path.lower() else "Checkpoint"
|
||||
},
|
||||
"files": [{"hashes": {"SHA256": file_hash.lower()}}]
|
||||
}
|
||||
info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
|
||||
atomic_write_json(info_path, info_data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_from_candidates(candidates, target_hash="", target_size=None, filename_query="", require_hash=False):
|
||||
target_hash = str(target_hash or "").strip().upper()
|
||||
# A saved filename/path is not identity evidence. It is intentionally
|
||||
# ignored here; exact path lookup for previews lives in the bounded
|
||||
# resolve_paths_to_previews endpoint and does not activate a model match.
|
||||
|
||||
has_target_hash = bool(target_hash and target_hash != "UNKNOWN")
|
||||
size_matches = [
|
||||
candidate for candidate in candidates
|
||||
if target_size is not None and candidate["size"] == target_size
|
||||
]
|
||||
|
||||
if has_target_hash and target_size is not None:
|
||||
combined_matches = [candidate for candidate in size_matches if target_hash in _candidate_hashes(candidate)]
|
||||
if len(combined_matches) == 1:
|
||||
return _resolved_payload(combined_matches[0], matched_by_hash=True, matched_by_size=True)
|
||||
if len(combined_matches) > 1:
|
||||
return {"found": False, "ambiguous": True}
|
||||
|
||||
# If no combined match found from static cache, but there are size-matching candidates without hash metadata,
|
||||
# dynamically compute their hash on-demand to test against target_hash
|
||||
unhashed_size_matches = [c for c in size_matches if not _candidate_hashes(c)]
|
||||
if unhashed_size_matches:
|
||||
from scraper import calculate_sha256
|
||||
for candidate in unhashed_size_matches:
|
||||
try:
|
||||
computed_hash = calculate_sha256(candidate["path"]).upper()
|
||||
candidate.setdefault("hashes", set()).add(computed_hash)
|
||||
if computed_hash == target_hash:
|
||||
_compute_and_save_fallback_info(candidate["path"], computed_hash)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dynamic_matches = [c for c in size_matches if target_hash in c.get("hashes", set())]
|
||||
if len(dynamic_matches) == 1:
|
||||
return _resolved_payload(dynamic_matches[0], matched_by_hash=True, matched_by_size=True)
|
||||
if len(dynamic_matches) > 1:
|
||||
return {"found": False, "ambiguous": True}
|
||||
|
||||
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
|
||||
if hash_matches or size_matches:
|
||||
return {"found": False, "identity_conflict": True}
|
||||
return {"found": False}
|
||||
|
||||
if has_target_hash:
|
||||
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
|
||||
if len(hash_matches) == 1:
|
||||
return _resolved_payload(hash_matches[0], matched_by_hash=True)
|
||||
if len(hash_matches) > 1:
|
||||
return {"found": False, "ambiguous": True}
|
||||
|
||||
if target_size is not None:
|
||||
if len(size_matches) == 1:
|
||||
return _size_candidate_payload(size_matches[0], require_hash=require_hash)
|
||||
if len(size_matches) > 1:
|
||||
return {"found": False, "ambiguous": True}
|
||||
if require_hash:
|
||||
return {"found": False, "hash_required": True}
|
||||
return {"found": False}
|
||||
|
||||
|
||||
async def api_resolve_hash(request):
|
||||
target_hash = request.query.get("hash", "").strip().upper()
|
||||
size_str = request.query.get("size", "").strip()
|
||||
filename_query = request.query.get("filename", "").strip()
|
||||
target_size = int(size_str) if size_str.isdigit() else None
|
||||
if not target_hash and target_size is None and not filename_query:
|
||||
return web.json_response({"found": False})
|
||||
try:
|
||||
types = _parse_resolution_types(request.query.get("type", "").strip())
|
||||
except ValueError as exc:
|
||||
return web.json_response({"found": False, "error": str(exc)}, status=400)
|
||||
|
||||
def resolve_one():
|
||||
candidates = _collect_resolution_candidates(types)
|
||||
return _resolve_from_candidates(
|
||||
candidates,
|
||||
target_hash,
|
||||
target_size,
|
||||
filename_query,
|
||||
require_hash=requires_hash_for_model_recovery(types),
|
||||
)
|
||||
|
||||
return web.json_response(await asyncio.to_thread(resolve_one))
|
||||
|
||||
|
||||
async def api_resolve_hash_batch(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
items = data.get("items", [])
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON body"}, status=400)
|
||||
if not isinstance(items, list) or len(items) > 256:
|
||||
return web.json_response({"error": "items must be a list with at most 256 entries"}, status=400)
|
||||
|
||||
parsed_items = []
|
||||
try:
|
||||
for index, item in enumerate(items):
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("Invalid batch item")
|
||||
size_value = item.get("size")
|
||||
size_string = str(size_value).strip() if size_value is not None else ""
|
||||
parsed_items.append({
|
||||
"key": str(item.get("key", index)),
|
||||
"hash": str(item.get("hash", "")).strip().upper(),
|
||||
"size": int(size_string) if size_string.isdigit() else None,
|
||||
"types": _parse_resolution_types(str(item.get("type", "")).strip()),
|
||||
})
|
||||
except (TypeError, ValueError) as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
|
||||
def resolve_batch():
|
||||
candidate_groups = {}
|
||||
results = []
|
||||
for item in parsed_items:
|
||||
types = item["types"]
|
||||
if types not in candidate_groups:
|
||||
candidate_groups[types] = _collect_resolution_candidates(types)
|
||||
result = _resolve_from_candidates(
|
||||
candidate_groups[types],
|
||||
item["hash"],
|
||||
item["size"],
|
||||
require_hash=requires_hash_for_model_recovery(types),
|
||||
)
|
||||
results.append({"key": item["key"], "result": result})
|
||||
return results
|
||||
|
||||
return web.json_response({"results": await asyncio.to_thread(resolve_batch)})
|
||||
|
||||
|
||||
def collect_model_hash_index():
|
||||
"""Scanned models keyed by relative path and basename -> {hash, size, url}.
|
||||
|
||||
Ambiguous keys (same name, different hash) are dropped. Shared by the
|
||||
all-hashes endpoint and the output gallery's hash search.
|
||||
"""
|
||||
hashes = {}
|
||||
ambiguous_keys = set()
|
||||
|
||||
def add_hash(key, value):
|
||||
if key in ambiguous_keys:
|
||||
return
|
||||
existing = hashes.get(key)
|
||||
if existing is None or existing == value:
|
||||
hashes[key] = value
|
||||
else:
|
||||
hashes.pop(key, None)
|
||||
ambiguous_keys.add(key)
|
||||
|
||||
types = RESOLVABLE_MODEL_TYPES
|
||||
seen_dirs = set()
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
if not paths: continue
|
||||
for base_dir in paths:
|
||||
if base_dir in seen_dirs: continue
|
||||
seen_dirs.add(base_dir)
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith(MODEL_EXTENSIONS):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
except Exception:
|
||||
size_bytes = 0
|
||||
|
||||
meta = get_metadata(file_path)
|
||||
hash_val = ""
|
||||
if meta and meta.get("hash"):
|
||||
hash_val = meta["hash"]
|
||||
model_url = ""
|
||||
if meta:
|
||||
model_url = meta.get("source_url") or meta.get("civitai_url") or ""
|
||||
|
||||
rel_path = os.path.relpath(file_path, base_dir)
|
||||
if rel_path.startswith('.\\') or rel_path.startswith('./'):
|
||||
rel_path = rel_path[2:]
|
||||
rel_path = rel_path.replace('\\', '/')
|
||||
basename = os.path.basename(file_path)
|
||||
|
||||
val = {"hash": hash_val, "size": size_bytes, "url": model_url}
|
||||
add_hash(rel_path, val)
|
||||
add_hash(basename, val)
|
||||
except Exception:
|
||||
pass
|
||||
return hashes
|
||||
|
||||
|
||||
async def api_get_all_hashes(request):
|
||||
"""
|
||||
Returns a dictionary of all scanned models with their hash and size.
|
||||
Keyed by both relative path and basename for maximum frontend resilience.
|
||||
"""
|
||||
hashes = await asyncio.to_thread(collect_model_hash_index)
|
||||
return web.json_response(hashes)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Compatibility exports for model API domains.
|
||||
|
||||
Route registration imports the focused modules directly; this module preserves
|
||||
the former import surface for third-party callers and tests.
|
||||
"""
|
||||
|
||||
import folder_paths
|
||||
|
||||
from .model_catalog import (
|
||||
_allowed_folder_types, _collect_all_scan_models, _collect_folder_models,
|
||||
_collect_folders, _find_model_sync, _iter_search_models, _model_info_for_path,
|
||||
_resolve_paths_to_model_info_sync, _resolve_paths_to_previews_sync,
|
||||
api_base_models, api_batch_select, api_compatible_models, api_find_model,
|
||||
api_get_all_scan_models, api_get_folders, api_get_models,
|
||||
api_resolve_paths_to_previews,
|
||||
)
|
||||
from .model_constants import *
|
||||
from .model_media import (
|
||||
_cache_token, _handle_custom_cover, _preview_url_for_model,
|
||||
api_set_custom_cover, api_upload_custom_cover,
|
||||
)
|
||||
from .model_metadata import (
|
||||
_first_existing_sidecar, _reset_model_cover, api_delete_model, api_update_metadata,
|
||||
)
|
||||
from .model_resolution import (
|
||||
_candidate_hashes, _collect_resolution_candidates, _compute_and_save_fallback_info,
|
||||
_parse_resolution_types, _resolve_from_candidates, _resolved_payload,
|
||||
_size_candidate_payload, api_get_all_hashes, api_resolve_hash,
|
||||
api_resolve_hash_batch,
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
from .utils import atomic_write_json, require_filename, resolve_within
|
||||
|
||||
|
||||
MAX_NOTEBOOK_BYTES = 2 * 1024 * 1024
|
||||
LEGACY_NOTEBOOKS_DIR = os.path.join(os.path.dirname(__file__), "notebooks")
|
||||
_notebooks_lock = threading.RLock()
|
||||
|
||||
|
||||
def _read_notebook(path):
|
||||
if os.path.getsize(path) > MAX_NOTEBOOK_BYTES:
|
||||
raise ValueError("Notebook is too large")
|
||||
with open(path, encoding="utf-8") as source:
|
||||
data = json.load(source)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Invalid notebook")
|
||||
return data
|
||||
|
||||
|
||||
def _migrate_notebooks(directory):
|
||||
marker = resolve_within(directory, ".legacy_imported.json")
|
||||
if os.path.exists(marker):
|
||||
return
|
||||
skipped = []
|
||||
if os.path.isdir(LEGACY_NOTEBOOKS_DIR):
|
||||
for filename in sorted(os.listdir(LEGACY_NOTEBOOKS_DIR)):
|
||||
if not filename.endswith(".json") or filename.startswith("."):
|
||||
continue
|
||||
try:
|
||||
source = resolve_within(LEGACY_NOTEBOOKS_DIR, require_filename(filename))
|
||||
data = _read_notebook(source)
|
||||
except (OSError, ValueError):
|
||||
skipped.append(filename)
|
||||
logging.warning("Anomalous: could not migrate notebook %s; original preserved", filename)
|
||||
continue
|
||||
target = resolve_within(directory, filename)
|
||||
if os.path.exists(target):
|
||||
try:
|
||||
if _read_notebook(target) == data:
|
||||
continue
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:12]
|
||||
recovered = f"{os.path.splitext(filename)[0][:100]}_legacy_{digest}.json"
|
||||
target = resolve_within(directory, recovered)
|
||||
if os.path.exists(target):
|
||||
if _read_notebook(target) != data:
|
||||
raise ValueError("Conflicting migrated notebook")
|
||||
continue
|
||||
atomic_write_json(target, data, MAX_NOTEBOOK_BYTES)
|
||||
atomic_write_json(marker, {"version": 1, "skipped": skipped})
|
||||
|
||||
|
||||
def get_notebooks_dir():
|
||||
user_dir = folder_paths.get_user_directory()
|
||||
directory = os.path.join(user_dir, "workflows", "anomalous_notebooks")
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
with _notebooks_lock:
|
||||
_migrate_notebooks(directory)
|
||||
return directory
|
||||
|
||||
|
||||
def _list_notebooks():
|
||||
with _notebooks_lock:
|
||||
directory = get_notebooks_dir()
|
||||
notebooks = []
|
||||
for filename in sorted(os.listdir(directory)):
|
||||
if not filename.endswith(".json") or filename.startswith("."):
|
||||
continue
|
||||
try:
|
||||
data = _read_notebook(resolve_within(directory, filename))
|
||||
notebooks.append({"filename": filename, "name": data.get("name", filename[:-5]), "data": data})
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return notebooks
|
||||
|
||||
|
||||
async def api_get_notebooks(request):
|
||||
try:
|
||||
notebooks = await asyncio.to_thread(_list_notebooks)
|
||||
return web.json_response({"notebooks": notebooks})
|
||||
except (OSError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Could not load notebooks"}, status=500)
|
||||
|
||||
|
||||
def _save_notebook(filename, data):
|
||||
with _notebooks_lock:
|
||||
atomic_write_json(resolve_within(get_notebooks_dir(), filename), data, MAX_NOTEBOOK_BYTES)
|
||||
|
||||
|
||||
async def api_save_notebook(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
if filename.startswith("."):
|
||||
raise ValueError("Invalid notebook filename")
|
||||
if not filename.endswith(".json"):
|
||||
filename += ".json"
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Invalid notebook")
|
||||
await asyncio.to_thread(_save_notebook, filename, data)
|
||||
return web.json_response({"status": "success"})
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not save notebook"}, status=500)
|
||||
|
||||
|
||||
def _delete_notebook(filename):
|
||||
with _notebooks_lock:
|
||||
os.remove(resolve_within(get_notebooks_dir(), filename))
|
||||
|
||||
|
||||
async def api_delete_notebook(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
if not filename.endswith(".json") or filename.startswith("."):
|
||||
raise ValueError("Invalid notebook filename")
|
||||
await asyncio.to_thread(_delete_notebook, filename)
|
||||
return web.json_response({"status": "success"})
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Notebook not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not delete notebook"}, status=500)
|
||||
@@ -0,0 +1,386 @@
|
||||
import asyncio
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import re
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
from .utils import require_filename, resolve_within
|
||||
from .recipe_constants import MAX_RECIPE_BYTES
|
||||
from .recipe_images import _parameter_gallery_images
|
||||
from .recipe_store import get_recipes_dir, _read_recipe
|
||||
from .workflow_schema import _parameter_signature
|
||||
|
||||
def get_parameters_dir():
|
||||
# Store parameter notebooks in the user directory
|
||||
# ComfyUI/user/default/workflows/anomalous_parameters
|
||||
user_dir = (
|
||||
folder_paths.get_user_directory()
|
||||
if hasattr(folder_paths, "get_user_directory")
|
||||
else None
|
||||
)
|
||||
if not user_dir:
|
||||
# Fallback if user_dir is not supported in this ComfyUI version
|
||||
base_dir = folder_paths.base_path
|
||||
user_dir = os.path.join(base_dir, "user", "default")
|
||||
|
||||
workflows_dir = os.path.join(user_dir, "workflows")
|
||||
parameters_dir = os.path.join(workflows_dir, "anomalous_parameters")
|
||||
|
||||
if not os.path.exists(parameters_dir):
|
||||
os.makedirs(parameters_dir, exist_ok=True)
|
||||
return parameters_dir
|
||||
|
||||
|
||||
|
||||
async def api_get_parameters(request):
|
||||
try:
|
||||
recipe_filename = request.query.get("recipe_filename")
|
||||
if recipe_filename:
|
||||
recipe_filename = require_filename(recipe_filename)
|
||||
if not recipe_filename.endswith(".json"):
|
||||
raise ValueError("Invalid recipe filename")
|
||||
except (AttributeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe filename"}, status=400)
|
||||
parameters_dir = get_parameters_dir()
|
||||
notebooks = await asyncio.to_thread(_read_parameter_notebooks, parameters_dir, recipe_filename)
|
||||
return web.json_response({"notebooks": notebooks})
|
||||
|
||||
|
||||
_parameter_notebooks_cache = None
|
||||
_parameter_notebooks_cache_time = 0
|
||||
|
||||
def _invalidate_parameter_notebooks_cache():
|
||||
global _parameter_notebooks_cache, _parameter_notebooks_cache_time
|
||||
_parameter_notebooks_cache = None
|
||||
_parameter_notebooks_cache_time = 0
|
||||
|
||||
def _prompt_role_for_node(node, params):
|
||||
"""Return an explicit role, or conservatively recover one from legacy values."""
|
||||
if not isinstance(node, dict) or not isinstance(params, dict):
|
||||
return None
|
||||
allowed_roles = {"positive", "negative", "both", "ignored", "unknown"}
|
||||
node_id = node.get("id")
|
||||
overrides = params.get("promptRoleOverrides")
|
||||
if isinstance(overrides, dict):
|
||||
override = overrides.get(str(node_id))
|
||||
if override is None:
|
||||
override = overrides.get(node_id)
|
||||
if isinstance(override, dict) and override.get("role") in allowed_roles:
|
||||
expected_type = override.get("nodeType")
|
||||
if not expected_type or expected_type == node.get("type"):
|
||||
return override["role"]
|
||||
|
||||
summaries = params.get("nodes")
|
||||
if isinstance(summaries, list):
|
||||
for summary in summaries:
|
||||
if not isinstance(summary, dict) or str(summary.get("id")) != str(node_id):
|
||||
continue
|
||||
if summary.get("role") in allowed_roles:
|
||||
return summary["role"]
|
||||
break
|
||||
|
||||
values = node.get("widgets_values")
|
||||
if not isinstance(values, list) or not values:
|
||||
return None
|
||||
value = values[0]
|
||||
positive_values = params.get("promptPositive", [])
|
||||
negative_values = params.get("promptNegative", [])
|
||||
positive = set(item for item in positive_values if isinstance(item, str)) if isinstance(positive_values, list) else set()
|
||||
negative = set(item for item in negative_values if isinstance(item, str)) if isinstance(negative_values, list) else set()
|
||||
if value in negative and value not in positive:
|
||||
return "negative"
|
||||
if value in positive and value not in negative:
|
||||
return "positive"
|
||||
return None
|
||||
|
||||
def _get_all_notebooks_cached(parameters_dir, force_refresh=False):
|
||||
global _parameter_notebooks_cache, _parameter_notebooks_cache_time
|
||||
# Cache for 2 seconds to prevent rapid disk I/O on UI interactions
|
||||
if not force_refresh and _parameter_notebooks_cache is not None and time.time() - _parameter_notebooks_cache_time < 2.0:
|
||||
return _parameter_notebooks_cache
|
||||
notebooks = _read_parameter_notebooks(parameters_dir)
|
||||
_parameter_notebooks_cache = notebooks
|
||||
_parameter_notebooks_cache_time = time.time()
|
||||
return notebooks
|
||||
|
||||
async def api_get_parameters_by_type(request):
|
||||
try:
|
||||
node_type = request.query.get("type")
|
||||
if not node_type:
|
||||
raise ValueError("Missing node type")
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Missing node type"}, status=400)
|
||||
|
||||
parameters_dir = get_parameters_dir()
|
||||
force_refresh = request.query.get("refresh") == "1"
|
||||
notebooks = await asyncio.to_thread(_get_all_notebooks_cached, parameters_dir, force_refresh)
|
||||
|
||||
# Filter and extract node data
|
||||
# Structure: [ { "recipe_filename": ..., "notebooks": [ { "name": ..., "nodes": [...] } ] } ]
|
||||
|
||||
grouped = {}
|
||||
for nb in notebooks:
|
||||
workflow = nb.get("data", {}).get("workflow")
|
||||
if not isinstance(workflow, dict):
|
||||
continue
|
||||
|
||||
nodes = workflow.get("nodes", [])
|
||||
if not isinstance(nodes, list):
|
||||
continue
|
||||
|
||||
matched_nodes = []
|
||||
for n in nodes:
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
if n.get("type") == node_type:
|
||||
matched_nodes.append({
|
||||
"id": n.get("id"),
|
||||
"title": n.get("title") or n.get("type"),
|
||||
"type": n.get("type"),
|
||||
"widgets_values": n.get("widgets_values", []),
|
||||
"role": _prompt_role_for_node(n, nb.get("data", {}).get("params")),
|
||||
})
|
||||
|
||||
if not matched_nodes:
|
||||
continue
|
||||
|
||||
recipe_fn = nb.get("data", {}).get("recipe_filename") or "unbound"
|
||||
if recipe_fn not in grouped:
|
||||
grouped[recipe_fn] = []
|
||||
|
||||
grouped[recipe_fn].append({
|
||||
"filename": nb.get("filename"),
|
||||
"name": nb.get("name"),
|
||||
"timestamp": nb.get("timestamp"),
|
||||
"nodes": matched_nodes
|
||||
})
|
||||
|
||||
recipes_dir = get_recipes_dir()
|
||||
recipe_name_cache = {}
|
||||
recipe_role_cache = {}
|
||||
|
||||
result = []
|
||||
for r_fn, nbs in grouped.items():
|
||||
recipe_roles = {}
|
||||
if r_fn == "unbound":
|
||||
human_name = "Unbound"
|
||||
else:
|
||||
if r_fn not in recipe_name_cache:
|
||||
try:
|
||||
r_data = _read_recipe(resolve_within(recipes_dir, r_fn))
|
||||
recipe_name_cache[r_fn] = r_data.get("name") if r_data else r_fn
|
||||
recipe_params = r_data.get("params") if isinstance(r_data, dict) else None
|
||||
if r_data and isinstance(recipe_params, dict):
|
||||
role_map = {}
|
||||
workflow = r_data.get("workflow")
|
||||
workflow_nodes = workflow.get("nodes", []) if isinstance(workflow, dict) else []
|
||||
for n in workflow_nodes:
|
||||
if not isinstance(n, dict) or "id" not in n:
|
||||
continue
|
||||
role = _prompt_role_for_node(n, recipe_params)
|
||||
if role is not None:
|
||||
role_map[n["id"]] = role
|
||||
recipe_role_cache[r_fn] = role_map
|
||||
except Exception:
|
||||
recipe_name_cache[r_fn] = r_fn
|
||||
human_name = recipe_name_cache[r_fn] or r_fn
|
||||
recipe_roles = recipe_role_cache.get(r_fn, {})
|
||||
|
||||
for nb in nbs:
|
||||
for n in nb["nodes"]:
|
||||
node_id = n.get("id")
|
||||
role = recipe_roles.get(node_id)
|
||||
if role is None:
|
||||
role = recipe_roles.get(str(node_id))
|
||||
if role is None:
|
||||
role = n.get("role")
|
||||
if role is not None:
|
||||
n["role"] = role
|
||||
|
||||
result.append({
|
||||
"recipe_filename": r_fn,
|
||||
"recipe_name": human_name,
|
||||
"notebooks": nbs
|
||||
})
|
||||
|
||||
return web.json_response({"groups": result})
|
||||
|
||||
def _read_parameter_notebooks(parameters_dir, recipe_filename=None):
|
||||
notebooks = []
|
||||
try:
|
||||
filenames = os.listdir(parameters_dir)
|
||||
except OSError:
|
||||
return notebooks
|
||||
for filename in filenames:
|
||||
if not filename.endswith(".json"):
|
||||
continue
|
||||
file_path = resolve_within(parameters_dir, filename)
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if recipe_filename and data.get("recipe_filename") != recipe_filename:
|
||||
continue
|
||||
notebooks.append({
|
||||
"filename": filename,
|
||||
"name": data.get("name", "Untitled Parameter Notebook"),
|
||||
"data": data,
|
||||
"timestamp": data.get("timestamp", 0),
|
||||
})
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
notebooks.sort(key=lambda item: item.get("timestamp", 0), reverse=True)
|
||||
return notebooks
|
||||
|
||||
async def api_save_parameter(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid parameter data"}, status=400)
|
||||
|
||||
if not isinstance(data, dict) or not isinstance(data.get("workflow"), dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid parameter workflow"}, status=400)
|
||||
|
||||
recipe_filename = data.get("recipe_filename")
|
||||
if recipe_filename is not None:
|
||||
try:
|
||||
recipe_filename = require_filename(recipe_filename)
|
||||
if not recipe_filename.endswith(".json"):
|
||||
raise ValueError("Invalid recipe filename")
|
||||
except (AttributeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe filename"}, status=400)
|
||||
data["recipe_filename"] = recipe_filename
|
||||
|
||||
filename_stem = os.path.splitext(recipe_filename)[0] if recipe_filename else "unbound"
|
||||
filename_stem = re.sub(r"[^A-Za-z0-9_-]+", "_", filename_stem)[:64]
|
||||
filename = f"params_{filename_stem}_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
|
||||
data["parameter_signature"] = _parameter_signature(data["workflow"])
|
||||
data["timestamp"] = int(time.time() * 1000)
|
||||
encoded = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
if len(encoded) > MAX_RECIPE_BYTES:
|
||||
return web.json_response({"status": "error", "message": "Parameter notebook is too large"}, status=413)
|
||||
|
||||
try:
|
||||
parameters_dir = get_parameters_dir()
|
||||
file_path = resolve_within(parameters_dir, filename)
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".parameter-", suffix=".tmp", dir=parameters_dir)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(temp_path, file_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
_invalidate_parameter_notebooks_cache()
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not save parameter notebook"}, status=500)
|
||||
|
||||
return web.json_response({"status": "success", "filename": filename})
|
||||
|
||||
async def api_delete_parameter(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = require_filename(data.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid filename")
|
||||
except (ValueError, AttributeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
|
||||
|
||||
try:
|
||||
parameters_dir = get_parameters_dir()
|
||||
file_path = resolve_within(parameters_dir, filename)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
_invalidate_parameter_notebooks_cache()
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not delete parameter notebook"}, status=500)
|
||||
|
||||
return web.json_response({"status": "success"})
|
||||
|
||||
async def api_rename_parameter(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = require_filename(data.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid filename")
|
||||
new_name = str(data.get("name", "")).strip()
|
||||
if not new_name:
|
||||
raise ValueError("Name cannot be empty")
|
||||
if len(new_name) > 200:
|
||||
new_name = new_name[:200]
|
||||
except (ValueError, AttributeError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid parameter data"}, status=400)
|
||||
|
||||
try:
|
||||
parameters_dir = get_parameters_dir()
|
||||
file_path = resolve_within(parameters_dir, filename)
|
||||
if not os.path.exists(file_path):
|
||||
return web.json_response({"status": "error", "message": "Parameter notebook not found"}, status=404)
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
nb_data = json.load(f)
|
||||
|
||||
if not isinstance(nb_data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid parameter file format"}, status=400)
|
||||
|
||||
nb_data["name"] = new_name
|
||||
nb_data["timestamp"] = int(time.time() * 1000)
|
||||
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".parameter-", suffix=".tmp", dir=parameters_dir)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(nb_data, f, ensure_ascii=False, separators=(",", ":"))
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(temp_path, file_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
_invalidate_parameter_notebooks_cache()
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not update parameter notebook"}, status=500)
|
||||
|
||||
return web.json_response({"status": "success", "filename": filename, "name": new_name})
|
||||
|
||||
async def api_get_parameter_gallery(request):
|
||||
"""Find recent output PNGs whose embedded parameters match one parameter notebook."""
|
||||
try:
|
||||
filename = request.query.get("filename")
|
||||
fingerprint = request.query.get("fingerprint")
|
||||
if filename:
|
||||
filename = require_filename(filename)
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid parameter notebook")
|
||||
parameters_dir = get_parameters_dir()
|
||||
file_path = resolve_within(parameters_dir, filename)
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
fingerprint = data.get("parameter_signature", {}).get("value")
|
||||
if not fingerprint:
|
||||
fingerprint = _parameter_signature(data.get("workflow"))["value"]
|
||||
elif not isinstance(fingerprint, str) or not re.fullmatch(r"[0-9a-f]{64}", fingerprint, re.IGNORECASE):
|
||||
raise ValueError("Invalid fingerprint")
|
||||
|
||||
images, scanned = await asyncio.to_thread(_parameter_gallery_images, fingerprint.lower())
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid parameter gallery request"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read parameter gallery"}, status=500)
|
||||
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"fingerprint": fingerprint.lower(),
|
||||
"match_mode": "params",
|
||||
"images": images,
|
||||
"scanned": scanned,
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Filesystem containment and atomic JSON helpers."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import folder_paths
|
||||
|
||||
from .recipe_constants import MODEL_FILE_SUFFIXES
|
||||
|
||||
|
||||
def resolve_within(base_dir, *parts):
|
||||
"""Resolve a path and require it to stay inside base_dir, including through symlinks."""
|
||||
base_real = os.path.realpath(base_dir)
|
||||
candidate = os.path.realpath(os.path.join(base_real, *parts))
|
||||
try:
|
||||
if os.path.commonpath([base_real, candidate]) != base_real:
|
||||
raise ValueError("Path escapes the configured directory")
|
||||
except ValueError:
|
||||
raise ValueError("Path escapes the configured directory")
|
||||
return candidate
|
||||
|
||||
|
||||
def _model_roots():
|
||||
for folder_type in getattr(folder_paths, "folder_names_and_paths", {}):
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
continue
|
||||
for path_index, base_dir in enumerate(paths or []):
|
||||
if os.path.isdir(base_dir):
|
||||
yield folder_type, path_index, os.path.realpath(base_dir)
|
||||
|
||||
|
||||
def _resolve_exact_model_reference(saved_value):
|
||||
"""Resolve one saved model value without recursive scanning or hashing."""
|
||||
if not isinstance(saved_value, str) or not saved_value.strip():
|
||||
return None
|
||||
relative_value = saved_value.replace("/", os.sep)
|
||||
for folder_type, path_index, base_dir in _model_roots():
|
||||
candidate = os.path.realpath(os.path.join(base_dir, relative_value))
|
||||
try:
|
||||
if os.path.commonpath((base_dir, candidate)) != base_dir:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES):
|
||||
continue
|
||||
return {
|
||||
"path": candidate,
|
||||
"folder_type": folder_type,
|
||||
"path_index": path_index,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def get_folder_root(folder_type, path_idx=0):
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
if not paths or not isinstance(path_idx, int) or path_idx < 0 or path_idx >= len(paths):
|
||||
raise ValueError("Invalid folder path")
|
||||
return os.path.realpath(paths[path_idx])
|
||||
|
||||
|
||||
def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'):
|
||||
base_dir = get_folder_root(folder_type, path_idx)
|
||||
relative = "" if subfolder in (None, "", "/") else str(subfolder).strip("/\\")
|
||||
return base_dir, resolve_within(base_dir, relative)
|
||||
|
||||
|
||||
def require_filename(filename):
|
||||
if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename:
|
||||
raise ValueError("Invalid filename")
|
||||
return filename
|
||||
|
||||
|
||||
def atomic_write_json(path, value, max_bytes=12 * 1024 * 1024):
|
||||
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
if len(encoded) > max_bytes:
|
||||
raise ValueError("JSON record is too large")
|
||||
fd, temporary = tempfile.mkstemp(prefix=".write-", suffix=".tmp", dir=os.path.dirname(path))
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as output:
|
||||
output.write(encoded)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.remove(temporary)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Shared bounded constants for Workflow Recipe domains."""
|
||||
|
||||
import re
|
||||
|
||||
MAX_NAME_LENGTH = 120
|
||||
MAX_TAGS = 20
|
||||
MAX_TAG_LENGTH = 60
|
||||
MAX_NOTES_LENGTH = 3000
|
||||
MAX_MODEL_NOTE_LENGTH = 1000
|
||||
MAX_THUMBNAIL_LENGTH = 1_500_000
|
||||
MAX_SOURCE_SUBFOLDER_LENGTH = 500
|
||||
MAX_RECIPE_BYTES = 12 * 1024 * 1024
|
||||
MAX_HISTORY_VERSIONS = 20
|
||||
MAX_PREVIEW_SNAPSHOTS = 12
|
||||
MAX_PREVIEW_SNAPSHOT_BYTES = 96 * 1024
|
||||
MAX_PREVIEW_SNAPSHOT_TOTAL_BYTES = 1_250_000
|
||||
MAX_PREVIEW_SOURCE_BYTES = 20 * 1024 * 1024
|
||||
MAX_WORKFLOW_NODES = 5_000
|
||||
MAX_WORKFLOW_LINKS = 30_000
|
||||
MAX_WORKFLOW_GROUPS = 2_000
|
||||
MAX_WIDGET_VALUES_PER_NODE = 2_048
|
||||
MAX_RECIPE_GALLERY_SCAN = 200
|
||||
MAX_RECIPE_GALLERY_RESULTS = 200
|
||||
MAX_EMBEDDED_WORKFLOW_BYTES = 3 * 1024 * 1024
|
||||
MAX_RECIPE_COVER_SOURCE_BYTES = 64 * 1024 * 1024
|
||||
MAX_RECIPE_COVER_BYTES = 256 * 1024
|
||||
SAFE_THUMBNAIL_PREFIXES = (
|
||||
"data:image/jpeg;base64,",
|
||||
"data:image/png;base64,",
|
||||
"data:image/webp;base64,",
|
||||
)
|
||||
MODEL_FILE_SUFFIXES = (".safetensors", ".ckpt", ".pt", ".bin", ".sft")
|
||||
STATIC_PREVIEW_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif")
|
||||
STATIC_PREVIEW_SUFFIXES = tuple(f".preview{extension}" for extension in STATIC_PREVIEW_EXTENSIONS)
|
||||
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$", re.IGNORECASE)
|
||||
VOLATILE_WORKFLOW_KEYS = frozenset({
|
||||
"seed",
|
||||
"noise_seed",
|
||||
"random_seed",
|
||||
"variation_seed",
|
||||
"batch_size",
|
||||
"batch_index",
|
||||
"batch_num",
|
||||
"last_seed",
|
||||
})
|
||||
VERIFIABLE_RECIPE_MODEL_CATEGORIES = frozenset({
|
||||
"checkpoint",
|
||||
"lora",
|
||||
"unet",
|
||||
"controlnet",
|
||||
"vae",
|
||||
"text_encoder",
|
||||
"clip_vision",
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Contained Recipe image assets and bounded output-gallery inspection."""
|
||||
|
||||
from io import BytesIO
|
||||
import hashlib
|
||||
import heapq
|
||||
import json
|
||||
import os
|
||||
|
||||
import folder_paths
|
||||
from PIL import Image
|
||||
|
||||
from .path_utils import _resolve_exact_model_reference
|
||||
from .recipe_constants import *
|
||||
from .utils import require_filename, resolve_within
|
||||
from .workflow_schema import _node_type, _parameter_signature, _workflow_node_signature
|
||||
|
||||
def _recipe_assets_dir(recipes_dir, filename, create=False):
|
||||
stem = os.path.splitext(require_filename(filename))[0]
|
||||
assets_dir = resolve_within(recipes_dir, ".assets", stem)
|
||||
if create:
|
||||
os.makedirs(assets_dir, exist_ok=True)
|
||||
return assets_dir
|
||||
|
||||
|
||||
def _preview_source_path(saved_value):
|
||||
resolved = _resolve_exact_model_reference(saved_value)
|
||||
if not resolved:
|
||||
return None
|
||||
base_path = os.path.splitext(resolved["path"])[0]
|
||||
for suffix in STATIC_PREVIEW_SUFFIXES + STATIC_PREVIEW_EXTENSIONS:
|
||||
candidate = f"{base_path}{suffix}"
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _thumbnail_webp_bytes(source_path):
|
||||
try:
|
||||
if os.path.getsize(source_path) > MAX_PREVIEW_SOURCE_BYTES:
|
||||
return None
|
||||
with Image.open(source_path) as image:
|
||||
image.thumbnail((320, 320))
|
||||
if image.mode not in ("RGB", "RGBA"):
|
||||
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
||||
width, height = image.size
|
||||
for quality in (76, 62, 48):
|
||||
output = BytesIO()
|
||||
image.save(output, format="WEBP", quality=quality, method=4)
|
||||
data = output.getvalue()
|
||||
if len(data) <= MAX_PREVIEW_SNAPSHOT_BYTES:
|
||||
return data, width, height
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _recipe_cover_webp_bytes(source_path):
|
||||
"""Create a portable recipe-cover asset without retaining the full output image."""
|
||||
try:
|
||||
if os.path.getsize(source_path) > MAX_RECIPE_COVER_SOURCE_BYTES:
|
||||
return None
|
||||
with Image.open(source_path) as image:
|
||||
image.thumbnail((640, 640))
|
||||
if image.mode not in ("RGB", "RGBA"):
|
||||
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
||||
width, height = image.size
|
||||
for quality in (84, 72, 60, 48):
|
||||
output = BytesIO()
|
||||
image.save(output, format="WEBP", quality=quality, method=4)
|
||||
data = output.getvalue()
|
||||
if len(data) <= MAX_RECIPE_COVER_BYTES:
|
||||
return data, width, height
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _output_source_path(source_image):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
target_dir = resolve_within(output_dir, source_image.get("subfolder", ""))
|
||||
source_path = resolve_within(target_dir, source_image["filename"])
|
||||
if not os.path.isfile(source_path):
|
||||
raise FileNotFoundError
|
||||
return source_path
|
||||
|
||||
|
||||
def _store_recipe_gallery_cover(recipes_dir, filename, source_image):
|
||||
source_path = _output_source_path(source_image)
|
||||
cover = _recipe_cover_webp_bytes(source_path)
|
||||
if not cover:
|
||||
raise ValueError("Could not create recipe cover")
|
||||
data, width, height = cover
|
||||
asset_id = f"cover-{hashlib.sha256(data).hexdigest()}.webp"
|
||||
asset_path = resolve_within(_recipe_assets_dir(recipes_dir, filename, create=True), asset_id)
|
||||
if not os.path.exists(asset_path):
|
||||
with open(asset_path, "wb") as asset_file:
|
||||
asset_file.write(data)
|
||||
return {
|
||||
"asset_id": asset_id,
|
||||
"media_type": "image/webp",
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
|
||||
|
||||
def _decode_embedded_json(value):
|
||||
if isinstance(value, bytes):
|
||||
if len(value) > MAX_EMBEDDED_WORKFLOW_BYTES:
|
||||
return None
|
||||
value = value.decode("utf-8")
|
||||
if not isinstance(value, str) or len(value.encode("utf-8")) > MAX_EMBEDDED_WORKFLOW_BYTES:
|
||||
return None
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def _embedded_workflow_payload(image_path):
|
||||
"""Read bounded workflow/prompt metadata from one PNG without loading pixels."""
|
||||
try:
|
||||
with Image.open(image_path) as image:
|
||||
return {
|
||||
"workflow": _decode_embedded_json(image.info.get("workflow")),
|
||||
"prompt": _decode_embedded_json(image.info.get("prompt")),
|
||||
}
|
||||
except (OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _embedded_workflow_node_signature(image_path):
|
||||
payload = _embedded_workflow_payload(image_path)
|
||||
if not payload:
|
||||
return None
|
||||
workflow = payload.get("workflow") or payload.get("prompt")
|
||||
if not workflow:
|
||||
return None
|
||||
return _workflow_node_signature(workflow)["value"]
|
||||
|
||||
|
||||
def _embedded_parameter_signature(image_path):
|
||||
payload = _embedded_workflow_payload(image_path)
|
||||
if not payload:
|
||||
return None
|
||||
workflow = payload.get("workflow") or payload.get("prompt")
|
||||
if not workflow:
|
||||
return None
|
||||
return _parameter_signature(workflow)["value"]
|
||||
|
||||
|
||||
def _recent_output_pngs(output_dir, limit=MAX_RECIPE_GALLERY_SCAN):
|
||||
"""Return a bounded newest-first PNG list without trusting request paths."""
|
||||
newest = []
|
||||
try:
|
||||
for root, _, files in os.walk(output_dir):
|
||||
for name in files:
|
||||
if not name.lower().endswith(".png"):
|
||||
continue
|
||||
path = os.path.join(root, name)
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
candidate = (stat.st_mtime_ns, path)
|
||||
if len(newest) < limit:
|
||||
heapq.heappush(newest, candidate)
|
||||
elif candidate > newest[0]:
|
||||
heapq.heapreplace(newest, candidate)
|
||||
except OSError:
|
||||
return []
|
||||
return sorted(newest, reverse=True)
|
||||
|
||||
|
||||
def _recipe_gallery_images(node_signature):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if not os.path.isdir(output_dir):
|
||||
return [], 0
|
||||
matches = []
|
||||
scanned = 0
|
||||
for mtime_ns, image_path in _recent_output_pngs(output_dir):
|
||||
scanned += 1
|
||||
if _embedded_workflow_node_signature(image_path) != node_signature:
|
||||
continue
|
||||
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
|
||||
subfolder = "" if relative_dir == "." else relative_dir.replace("\\", "/")
|
||||
matches.append({
|
||||
"filename": os.path.basename(image_path),
|
||||
"subfolder": subfolder,
|
||||
"type": "output",
|
||||
"mtime": mtime_ns // 1_000_000,
|
||||
})
|
||||
if len(matches) >= MAX_RECIPE_GALLERY_RESULTS:
|
||||
break
|
||||
return matches, scanned
|
||||
|
||||
|
||||
def _parameter_gallery_images(parameter_signature):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if not os.path.isdir(output_dir):
|
||||
return [], 0
|
||||
matches = []
|
||||
scanned = 0
|
||||
for mtime_ns, image_path in _recent_output_pngs(output_dir):
|
||||
scanned += 1
|
||||
if _embedded_parameter_signature(image_path) != parameter_signature:
|
||||
continue
|
||||
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
|
||||
subfolder = "" if relative_dir == "." else relative_dir.replace("\\", "/")
|
||||
matches.append({
|
||||
"filename": os.path.basename(image_path),
|
||||
"subfolder": subfolder,
|
||||
"type": "output",
|
||||
"mtime": mtime_ns // 1_000_000,
|
||||
})
|
||||
if len(matches) >= MAX_RECIPE_GALLERY_RESULTS:
|
||||
break
|
||||
return matches, scanned
|
||||
|
||||
|
||||
def _bounded_gallery_value(value, depth=0):
|
||||
"""Keep image-embedded parameter details useful without echoing huge metadata."""
|
||||
if depth > 5:
|
||||
return "…"
|
||||
if isinstance(value, str):
|
||||
return value if len(value) <= 2000 else f"{value[:1997]}..."
|
||||
if isinstance(value, (int, float, bool)) or value is None:
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return [_bounded_gallery_value(item, depth + 1) for item in value[:64]]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): _bounded_gallery_value(nested, depth + 1)
|
||||
for key, nested in list(value.items())[:96]
|
||||
}
|
||||
return str(value)[:2000]
|
||||
|
||||
|
||||
def _gallery_parameter_records(workflow):
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
nodes = workflow.get("nodes")
|
||||
is_ui_workflow = isinstance(nodes, list)
|
||||
values = nodes if is_ui_workflow else workflow.values()
|
||||
records = []
|
||||
occurrence = {}
|
||||
for node in values:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_type = _node_type(node) or str(node.get("class_type") or "").strip()
|
||||
if not node_type:
|
||||
continue
|
||||
occurrence[node_type] = occurrence.get(node_type, 0) + 1
|
||||
if is_ui_workflow:
|
||||
parameters = {"widgets": _bounded_gallery_value(node.get("widgets_values", []))}
|
||||
node_id = node.get("id")
|
||||
else:
|
||||
parameters = {"inputs": _bounded_gallery_value(node.get("inputs", {}))}
|
||||
node_id = None
|
||||
records.append({
|
||||
"type": node_type,
|
||||
"index": occurrence[node_type],
|
||||
"node_id": node_id,
|
||||
"parameters": parameters,
|
||||
})
|
||||
return records
|
||||
|
||||
|
||||
def _gallery_parameter_diff(recipe_workflow, image_payload):
|
||||
recipe_records = _gallery_parameter_records(recipe_workflow)
|
||||
embedded_workflow = image_payload.get("workflow") or image_payload.get("prompt") if image_payload else None
|
||||
image_records = _gallery_parameter_records(embedded_workflow)
|
||||
recipe_map = {(item["type"], item["index"]): item for item in recipe_records}
|
||||
image_map = {(item["type"], item["index"]): item for item in image_records}
|
||||
changes = []
|
||||
for key in sorted(set(recipe_map) | set(image_map), key=lambda item: (item[0].casefold(), item[1])):
|
||||
recipe_item = recipe_map.get(key)
|
||||
image_item = image_map.get(key)
|
||||
if recipe_item is None or image_item is None or recipe_item["parameters"] != image_item["parameters"]:
|
||||
changes.append({
|
||||
"type": key[0],
|
||||
"index": key[1],
|
||||
"recipe": recipe_item["parameters"] if recipe_item else None,
|
||||
"image": image_item["parameters"] if image_item else None,
|
||||
})
|
||||
return {
|
||||
"recipe_nodes": recipe_records,
|
||||
"image_nodes": image_records,
|
||||
"changes": changes,
|
||||
"comparable": bool(image_records) and all(
|
||||
isinstance(item.get("workflow"), dict) for item in [image_payload or {}]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _attach_preview_snapshots(recipe, recipes_dir, filename, references):
|
||||
if not recipe.get("presentation", {}).get("save_model_preview_snapshots"):
|
||||
return
|
||||
assets_dir = _recipe_assets_dir(recipes_dir, filename, create=True)
|
||||
total_bytes = 0
|
||||
for reference in references[:MAX_PREVIEW_SNAPSHOTS]:
|
||||
source_path = _preview_source_path(reference.get("saved_value"))
|
||||
if not source_path:
|
||||
continue
|
||||
thumbnail = _thumbnail_webp_bytes(source_path)
|
||||
if not thumbnail:
|
||||
continue
|
||||
data, width, height = thumbnail
|
||||
if total_bytes + len(data) > MAX_PREVIEW_SNAPSHOT_TOTAL_BYTES:
|
||||
break
|
||||
asset_id = f"{hashlib.sha256(data).hexdigest()}.webp"
|
||||
asset_path = resolve_within(assets_dir, asset_id)
|
||||
if not os.path.exists(asset_path):
|
||||
with open(asset_path, "wb") as asset_file:
|
||||
asset_file.write(data)
|
||||
total_bytes += len(data)
|
||||
reference["preview"] = {
|
||||
"snapshot_asset_id": asset_id,
|
||||
"media_type": "image/webp",
|
||||
"width": width,
|
||||
"height": height,
|
||||
"captured_at": recipe["timestamp"],
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
"""Bounded import/export for self-contained Workflow Recipe packages."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import recipe_images, recipe_schema, recipe_store
|
||||
from .recipe_store import get_recipes_dir
|
||||
from .utils import require_filename, resolve_within
|
||||
|
||||
|
||||
# Release gates: package transfers stay closed until validation is complete.
|
||||
# Keep the format implementation for compatibility testing.
|
||||
RECIPE_PACKAGE_EXPORT_ENABLED = False
|
||||
RECIPE_PACKAGE_IMPORT_ENABLED = False
|
||||
PACKAGE_VERSION = 1
|
||||
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
|
||||
MAX_ENTRY_COUNT = 256
|
||||
MAX_ENTRY_BYTES = 16 * 1024 * 1024
|
||||
MAX_EXPANDED_BYTES = 64 * 1024 * 1024
|
||||
MAX_COMPRESSION_RATIO = 100
|
||||
MAX_HISTORY_ENTRIES = 100
|
||||
MAX_JSON_DEPTH = 64
|
||||
MAX_INSPECTIONS = 4
|
||||
INSPECTION_TTL_SECONDS = 10 * 60
|
||||
ALLOWED_COMPRESSION = {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
|
||||
_INSPECTIONS = {}
|
||||
|
||||
|
||||
def _json_depth(value, depth=0):
|
||||
if depth > MAX_JSON_DEPTH:
|
||||
return depth
|
||||
if isinstance(value, dict):
|
||||
return max((_json_depth(item, depth + 1) for item in value.values()), default=depth)
|
||||
if isinstance(value, list):
|
||||
return max((_json_depth(item, depth + 1) for item in value), default=depth)
|
||||
return depth
|
||||
|
||||
|
||||
def _sha256(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _safe_archive_name(name):
|
||||
if not isinstance(name, str) or not name or len(name) > 240:
|
||||
raise ValueError("Invalid package entry name")
|
||||
if chr(92) in name or name.startswith("/") or ":" in name:
|
||||
raise ValueError("Invalid package entry name")
|
||||
parts = name.split("/")
|
||||
if any(not part or part in (".", "..") for part in parts):
|
||||
raise ValueError("Invalid package entry name")
|
||||
return name
|
||||
|
||||
|
||||
def _entry_is_symlink(info):
|
||||
return stat.S_ISLNK((info.external_attr >> 16) & 0xFFFF)
|
||||
|
||||
|
||||
def _read_zip_entry(archive, name, maximum=MAX_ENTRY_BYTES):
|
||||
info = archive.getinfo(name)
|
||||
if info.file_size > maximum:
|
||||
raise ValueError("Package entry is too large")
|
||||
data = archive.read(info)
|
||||
if len(data) != info.file_size:
|
||||
raise ValueError("Package entry size mismatch")
|
||||
return data
|
||||
|
||||
|
||||
def _validate_zip(raw):
|
||||
if not isinstance(raw, bytes) or len(raw) > MAX_UPLOAD_BYTES:
|
||||
raise ValueError("Package upload is too large")
|
||||
try:
|
||||
archive = zipfile.ZipFile(io.BytesIO(raw), "r")
|
||||
except (OSError, zipfile.BadZipFile) as error:
|
||||
raise ValueError("Invalid recipe package") from error
|
||||
|
||||
infos = archive.infolist()
|
||||
if len(infos) > MAX_ENTRY_COUNT:
|
||||
raise ValueError("Too many package entries")
|
||||
names = []
|
||||
expanded = 0
|
||||
for info in infos:
|
||||
name = _safe_archive_name(info.filename)
|
||||
if name in names or info.is_dir() or _entry_is_symlink(info):
|
||||
raise ValueError("Invalid or duplicate package entry")
|
||||
if info.compress_type not in ALLOWED_COMPRESSION or info.flag_bits & 0x1:
|
||||
raise ValueError("Unsupported package compression")
|
||||
if info.file_size > MAX_ENTRY_BYTES:
|
||||
raise ValueError("Package entry is too large")
|
||||
if info.compress_size and info.file_size / info.compress_size > MAX_COMPRESSION_RATIO:
|
||||
raise ValueError("Package compression ratio is unsafe")
|
||||
expanded += info.file_size
|
||||
if expanded > MAX_EXPANDED_BYTES:
|
||||
raise ValueError("Package expands beyond the safety limit")
|
||||
names.append(name)
|
||||
|
||||
if "manifest.json" not in names or "recipe.json" not in names:
|
||||
raise ValueError("Package is missing manifest or recipe")
|
||||
return archive, names
|
||||
|
||||
|
||||
def _parse_json(data, label):
|
||||
try:
|
||||
value = json.loads(data.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"Invalid {label}") from error
|
||||
if _json_depth(value) > MAX_JSON_DEPTH:
|
||||
raise ValueError(f"{label} is nested too deeply")
|
||||
return value
|
||||
|
||||
|
||||
def _asset_id_from_reference(reference):
|
||||
asset_id = reference.get("preview", {}).get("snapshot_asset_id") if isinstance(reference, dict) else None
|
||||
if not isinstance(asset_id, str) or not asset_id.endswith(".webp"):
|
||||
return None
|
||||
return require_filename(asset_id)
|
||||
|
||||
|
||||
def _cover_asset_id(recipe):
|
||||
presentation = recipe.get("presentation", {}) if isinstance(recipe, dict) else {}
|
||||
asset_id = presentation.get("cover_asset_id") if isinstance(presentation, dict) else None
|
||||
if not isinstance(asset_id, str) or not asset_id.startswith("cover-") or not asset_id.endswith(".webp"):
|
||||
return None
|
||||
return require_filename(asset_id)
|
||||
|
||||
|
||||
def _validate_manifest(manifest, archive, names):
|
||||
if not isinstance(manifest, dict) or manifest.get("package_version") != PACKAGE_VERSION:
|
||||
raise ValueError("Unsupported recipe package version")
|
||||
declared = manifest.get("entries", [])
|
||||
if not isinstance(declared, list) or len(declared) > MAX_ENTRY_COUNT:
|
||||
raise ValueError("Invalid package manifest")
|
||||
declared_map = {}
|
||||
for item in declared:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("Invalid package manifest entry")
|
||||
name = _safe_archive_name(item.get("name"))
|
||||
if name in declared_map or name not in names:
|
||||
raise ValueError("Package manifest does not match archive")
|
||||
declared_map[name] = item
|
||||
for name in names:
|
||||
if name == "manifest.json":
|
||||
continue
|
||||
item = declared_map.get(name)
|
||||
if not item:
|
||||
raise ValueError("Package contains undeclared data")
|
||||
data = _read_zip_entry(archive, name)
|
||||
if item.get("sha256") != _sha256(data) or item.get("size") != len(data):
|
||||
raise ValueError("Package checksum mismatch")
|
||||
|
||||
|
||||
def _sanitize_recipe_for_export(recipe, include_snapshots=True, include_identity=True, include_model_notes=True):
|
||||
value = json.loads(json.dumps(recipe, ensure_ascii=False))
|
||||
# A source_image points into the exporting machine's output directory and
|
||||
# is not portable package data. The bounded thumbnail remains the cover.
|
||||
value["source_image"] = None
|
||||
for reference in value.get("params", {}).get("model_references", []):
|
||||
if not include_model_notes:
|
||||
reference.pop("user_note", None)
|
||||
if not include_snapshots:
|
||||
reference.pop("preview", None)
|
||||
if not include_identity:
|
||||
identity = reference.get("identity")
|
||||
if isinstance(identity, dict):
|
||||
for key in ("sha256", "size", "provenance"):
|
||||
identity.pop(key, None)
|
||||
identity["status"] = "unverified"
|
||||
return value
|
||||
|
||||
|
||||
def _referenced_asset_ids(recipe, include_model_previews=True):
|
||||
result = set()
|
||||
cover_asset = _cover_asset_id(recipe)
|
||||
if cover_asset:
|
||||
result.add(cover_asset)
|
||||
if include_model_previews:
|
||||
references = recipe.get("params", {}).get("model_references", [])
|
||||
for reference in references if isinstance(references, list) else []:
|
||||
asset_id = _asset_id_from_reference(reference)
|
||||
if asset_id:
|
||||
result.add(asset_id)
|
||||
return result
|
||||
|
||||
|
||||
def _add_zip_entry(archive, entries, name, data, media_type):
|
||||
name = _safe_archive_name(name)
|
||||
archive.writestr(name, data)
|
||||
entries.append({"name": name, "size": len(data), "sha256": _sha256(data), "media_type": media_type})
|
||||
|
||||
|
||||
def _recipe_history_files(recipes_dir, filename):
|
||||
history_dir = recipe_store._history_dir(recipes_dir, filename)
|
||||
files = []
|
||||
if not os.path.isdir(history_dir):
|
||||
return files
|
||||
with os.scandir(history_dir) as entries:
|
||||
for entry in entries:
|
||||
if entry.is_file() and entry.name.endswith(".json"):
|
||||
files.append(require_filename(entry.name))
|
||||
files.sort()
|
||||
return files[:MAX_HISTORY_ENTRIES]
|
||||
|
||||
|
||||
def _build_export(raw_recipe, recipes_dir, filename, options):
|
||||
include_snapshots = options.get("include_snapshots") is True
|
||||
include_history = options.get("include_history") is True
|
||||
include_identity = options.get("include_identity", True) is True
|
||||
include_model_notes = options.get("include_model_notes", True) is True
|
||||
recipe = _sanitize_recipe_for_export(raw_recipe, include_snapshots, include_identity, include_model_notes)
|
||||
history_recipes = []
|
||||
if include_history:
|
||||
for history_name in _recipe_history_files(recipes_dir, filename):
|
||||
history_path = resolve_within(recipe_store._history_dir(recipes_dir, filename), history_name)
|
||||
with open(history_path, "rb") as history_file:
|
||||
data = history_file.read(MAX_ENTRY_BYTES + 1)
|
||||
if len(data) > MAX_ENTRY_BYTES:
|
||||
raise ValueError("Historical recipe is too large")
|
||||
history_recipe = _parse_json(data, "historical recipe")
|
||||
history_recipes.append((history_name, _sanitize_recipe_for_export(
|
||||
history_recipe,
|
||||
include_snapshots,
|
||||
include_identity,
|
||||
include_model_notes,
|
||||
)))
|
||||
asset_ids = _referenced_asset_ids(recipe, include_snapshots)
|
||||
for _, history_recipe in history_recipes:
|
||||
asset_ids.update(_referenced_asset_ids(history_recipe, include_snapshots))
|
||||
entries = []
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
||||
recipe_bytes = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
_add_zip_entry(archive, entries, "recipe.json", recipe_bytes, "application/json")
|
||||
|
||||
if asset_ids:
|
||||
assets_dir = recipe_images._recipe_assets_dir(recipes_dir, filename)
|
||||
for asset_id in sorted(asset_ids):
|
||||
asset_path = resolve_within(assets_dir, asset_id)
|
||||
if not os.path.isfile(asset_path):
|
||||
continue
|
||||
with open(asset_path, "rb") as asset_file:
|
||||
data = asset_file.read(MAX_ENTRY_BYTES + 1)
|
||||
if len(data) > MAX_ENTRY_BYTES or not data.startswith(b"RIFF") or data[8:12] != b"WEBP":
|
||||
continue
|
||||
_add_zip_entry(archive, entries, f"assets/{asset_id}", data, "image/webp")
|
||||
|
||||
if include_history:
|
||||
for history_name, history_recipe in history_recipes:
|
||||
data = json.dumps(history_recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
_add_zip_entry(archive, entries, f"history/{history_name}", data, "application/json")
|
||||
|
||||
manifest = {
|
||||
"package_version": PACKAGE_VERSION,
|
||||
"recipe_schema_version": recipe.get("schema_version", 1),
|
||||
"exported_at": int(time.time() * 1000),
|
||||
"options": {
|
||||
"include_snapshots": include_snapshots,
|
||||
"include_history": include_history,
|
||||
"include_identity": include_identity,
|
||||
"include_model_notes": include_model_notes,
|
||||
},
|
||||
"entries": entries,
|
||||
}
|
||||
manifest_bytes = json.dumps(manifest, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
archive.writestr("manifest.json", manifest_bytes)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _validate_recipe_assets(archive, recipes, names):
|
||||
referenced = set()
|
||||
for recipe in recipes:
|
||||
referenced.update(_referenced_asset_ids(recipe))
|
||||
for asset_id in referenced:
|
||||
name = f"assets/{asset_id}"
|
||||
if name not in names:
|
||||
raise ValueError("Recipe references a missing snapshot asset")
|
||||
data = _read_zip_entry(archive, name)
|
||||
if not data.startswith(b"RIFF") or data[8:12] != b"WEBP":
|
||||
raise ValueError("Snapshot asset is not a WebP image")
|
||||
for name in names:
|
||||
if name.startswith("assets/") and (name[7:] not in referenced or not name.endswith(".webp")):
|
||||
raise ValueError("Package contains an unreferenced asset")
|
||||
|
||||
|
||||
def _inspect_package(raw):
|
||||
archive, names = _validate_zip(raw)
|
||||
with archive:
|
||||
manifest = _parse_json(_read_zip_entry(archive, "manifest.json"), "manifest")
|
||||
_validate_manifest(manifest, archive, names)
|
||||
recipe = _parse_json(_read_zip_entry(archive, "recipe.json"), "recipe")
|
||||
recipe_schema._normalise_recipe(recipe)
|
||||
history_names = [name for name in names if name.startswith("history/")]
|
||||
if len(history_names) > MAX_HISTORY_ENTRIES:
|
||||
raise ValueError("Too many history entries")
|
||||
history_recipes = []
|
||||
for name in history_names:
|
||||
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
|
||||
recipe_schema._normalise_recipe(history)
|
||||
history_recipes.append(history)
|
||||
_validate_recipe_assets(archive, [recipe, *history_recipes], names)
|
||||
return {
|
||||
"manifest": manifest,
|
||||
"recipe": recipe,
|
||||
"history_names": history_names,
|
||||
"asset_names": [name for name in names if name.startswith("assets/")],
|
||||
}
|
||||
|
||||
|
||||
def _prune_inspections():
|
||||
now = time.time()
|
||||
for token, record in list(_INSPECTIONS.items()):
|
||||
if now - record["created"] > INSPECTION_TTL_SECONDS:
|
||||
_INSPECTIONS.pop(token, None)
|
||||
while len(_INSPECTIONS) >= MAX_INSPECTIONS:
|
||||
oldest = min(_INSPECTIONS, key=lambda token: _INSPECTIONS[token]["created"])
|
||||
_INSPECTIONS.pop(oldest, None)
|
||||
|
||||
|
||||
def _existing_recipe_names(recipes_dir):
|
||||
return [item["data"].get("name", "") for item in recipe_store._list_recipes(recipes_dir)]
|
||||
|
||||
|
||||
def _unique_name(name, existing):
|
||||
base = name.strip() or "Imported Workflow Recipe"
|
||||
if base not in existing:
|
||||
return base
|
||||
for index in range(2, 1000):
|
||||
candidate = f"{base} ({index})"
|
||||
if candidate not in existing:
|
||||
return candidate
|
||||
raise ValueError("Could not create a unique recipe name")
|
||||
|
||||
|
||||
def _copy_imported_assets(archive, inspection, staging, source_recipe, target_recipe):
|
||||
asset_dir = os.path.join(staging, "assets")
|
||||
os.makedirs(asset_dir, exist_ok=True)
|
||||
for name in inspection["asset_names"]:
|
||||
asset_id = require_filename(name.split("/", 1)[1])
|
||||
data = _read_zip_entry(archive, name)
|
||||
with open(resolve_within(asset_dir, asset_id), "wb") as asset_file:
|
||||
asset_file.write(data)
|
||||
|
||||
source_refs = {
|
||||
(
|
||||
item.get("node_id"),
|
||||
item.get("widget_index"),
|
||||
item.get("category"),
|
||||
item.get("saved_value"),
|
||||
): item
|
||||
for item in source_recipe.get("params", {}).get("model_references", [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
for target in target_recipe.get("params", {}).get("model_references", []):
|
||||
source = source_refs.get((target.get("node_id"), target.get("widget_index"), target.get("category"), target.get("saved_value")))
|
||||
asset_id = _asset_id_from_reference(source or {})
|
||||
if asset_id and os.path.isfile(resolve_within(asset_dir, asset_id)):
|
||||
target["preview"] = source["preview"]
|
||||
|
||||
|
||||
def _write_staged_json(path, value):
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as output:
|
||||
json.dump(value, output, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _commit_import(record, payload):
|
||||
recipes_dir = get_recipes_dir()
|
||||
package_recipe = json.loads(json.dumps(record["recipe"], ensure_ascii=False))
|
||||
collision = payload.get("collision", "rename")
|
||||
name_override = payload.get("name")
|
||||
if name_override is not None:
|
||||
if not isinstance(name_override, str) or not name_override.strip():
|
||||
raise ValueError("Invalid imported recipe name")
|
||||
package_recipe["name"] = name_override.strip()
|
||||
|
||||
existing_names = _existing_recipe_names(recipes_dir)
|
||||
target_filename = None
|
||||
if collision == "replace":
|
||||
target_filename = require_filename(payload.get("target_filename", ""))
|
||||
if not target_filename.endswith(".json"):
|
||||
raise ValueError("Invalid replacement target")
|
||||
if not os.path.isfile(resolve_within(recipes_dir, target_filename)):
|
||||
raise FileNotFoundError
|
||||
elif collision != "rename":
|
||||
raise ValueError("Unsupported collision choice")
|
||||
else:
|
||||
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), existing_names)
|
||||
|
||||
normalized = recipe_schema._normalise_recipe(package_recipe)
|
||||
normalized["presentation"]["imported"] = True
|
||||
filename = target_filename or f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
|
||||
normalized = recipe_schema._enrich_recipe(normalized)
|
||||
|
||||
staging = tempfile.mkdtemp(prefix=".recipe-import-", dir=recipes_dir)
|
||||
final_path = resolve_within(recipes_dir, filename)
|
||||
final_assets = recipe_images._recipe_assets_dir(recipes_dir, filename)
|
||||
final_history = recipe_store._history_dir(recipes_dir, filename)
|
||||
backup_recipe = None
|
||||
backup_assets = None
|
||||
backup_history = None
|
||||
installed_assets = False
|
||||
installed_history = False
|
||||
try:
|
||||
archive, _ = _validate_zip(record["raw"])
|
||||
with archive:
|
||||
_copy_imported_assets(archive, record, staging, record["recipe"], normalized)
|
||||
_write_staged_json(os.path.join(staging, "recipe.json"), normalized)
|
||||
if record["history_names"]:
|
||||
history_stage = os.path.join(staging, "history")
|
||||
os.makedirs(history_stage, exist_ok=True)
|
||||
for name in record["history_names"]:
|
||||
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
|
||||
_write_staged_json(os.path.join(history_stage, require_filename(name.split("/", 1)[1])), history)
|
||||
|
||||
if collision == "replace":
|
||||
backup_recipe = os.path.join(staging, "old-recipe.json")
|
||||
os.replace(final_path, backup_recipe)
|
||||
if os.path.isdir(final_assets):
|
||||
backup_assets = os.path.join(staging, "old-assets")
|
||||
shutil.move(final_assets, backup_assets)
|
||||
if record["history_names"] and os.path.isdir(final_history):
|
||||
backup_history = os.path.join(staging, "old-history")
|
||||
shutil.move(final_history, backup_history)
|
||||
|
||||
os.replace(os.path.join(staging, "recipe.json"), final_path)
|
||||
staged_assets = os.path.join(staging, "assets")
|
||||
if os.path.isdir(staged_assets):
|
||||
os.replace(staged_assets, final_assets)
|
||||
installed_assets = True
|
||||
if record["history_names"]:
|
||||
history_stage = os.path.join(staging, "history")
|
||||
if os.path.isdir(final_history):
|
||||
shutil.rmtree(final_history)
|
||||
os.replace(history_stage, final_history)
|
||||
installed_history = True
|
||||
except Exception:
|
||||
if collision == "replace" and backup_recipe and os.path.isfile(backup_recipe):
|
||||
if installed_assets and os.path.isdir(final_assets):
|
||||
shutil.rmtree(final_assets, ignore_errors=True)
|
||||
if installed_history and os.path.isdir(final_history):
|
||||
shutil.rmtree(final_history, ignore_errors=True)
|
||||
if os.path.isfile(final_path):
|
||||
os.remove(final_path)
|
||||
os.replace(backup_recipe, final_path)
|
||||
if backup_assets and os.path.isdir(backup_assets):
|
||||
shutil.move(backup_assets, final_assets)
|
||||
if backup_history and os.path.isdir(backup_history):
|
||||
shutil.move(backup_history, final_history)
|
||||
raise
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
return filename, normalized.get("name", "")
|
||||
|
||||
|
||||
async def api_export_recipe_package(request):
|
||||
if not RECIPE_PACKAGE_EXPORT_ENABLED:
|
||||
return web.json_response({
|
||||
"status": "error",
|
||||
"code": "recipe_export_disabled",
|
||||
"message": "Recipe package export is temporarily unavailable",
|
||||
}, status=503)
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid recipe filename")
|
||||
recipes_dir = get_recipes_dir()
|
||||
recipe = await asyncio.to_thread(recipe_store._read_recipe, resolve_within(recipes_dir, filename))
|
||||
package = await asyncio.to_thread(_build_export, recipe, recipes_dir, filename, payload)
|
||||
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid export request"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Recipe not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not export recipe"}, status=500)
|
||||
return web.Response(
|
||||
body=package,
|
||||
content_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{os.path.splitext(filename)[0]}.anomalous-recipe.zip"'},
|
||||
)
|
||||
|
||||
|
||||
async def api_import_recipe_package_inspect(request):
|
||||
if not RECIPE_PACKAGE_IMPORT_ENABLED:
|
||||
return web.json_response({
|
||||
"status": "error", "code": "recipe_import_disabled",
|
||||
"message": "Recipe package import is temporarily unavailable",
|
||||
}, status=503)
|
||||
try:
|
||||
raw = await request.content.read(MAX_UPLOAD_BYTES + 1)
|
||||
report = await asyncio.to_thread(_inspect_package, raw)
|
||||
except (ValueError, OSError, zipfile.BadZipFile):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe package"}, status=400)
|
||||
_prune_inspections()
|
||||
token = uuid.uuid4().hex
|
||||
_INSPECTIONS[token] = {"created": time.time(), "raw": raw, **report}
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"token": token,
|
||||
"recipe": {"name": report["recipe"].get("name", ""), "tags": report["recipe"].get("tags", [])},
|
||||
"asset_count": len(report["asset_names"]),
|
||||
"history_count": len(report["history_names"]),
|
||||
"existing_names": _existing_recipe_names(get_recipes_dir()),
|
||||
})
|
||||
|
||||
|
||||
async def api_import_recipe_package_commit(request):
|
||||
if not RECIPE_PACKAGE_IMPORT_ENABLED:
|
||||
return web.json_response({
|
||||
"status": "error", "code": "recipe_import_disabled",
|
||||
"message": "Recipe package import is temporarily unavailable",
|
||||
}, status=503)
|
||||
try:
|
||||
payload = await request.json()
|
||||
token = payload.get("token")
|
||||
if not isinstance(token, str) or token not in _INSPECTIONS:
|
||||
raise ValueError("Invalid inspection token")
|
||||
record = _INSPECTIONS.pop(token)
|
||||
if time.time() - record["created"] > INSPECTION_TTL_SECONDS:
|
||||
raise ValueError("Inspection token expired")
|
||||
filename, name = await asyncio.to_thread(_commit_import, record, payload)
|
||||
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Replacement recipe not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=500)
|
||||
return web.json_response({"status": "success", "filename": filename, "name": name})
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Workflow Recipe record normalization, model references, and enrichment."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
import folder_paths
|
||||
|
||||
from .metadata import get_metadata
|
||||
from .recipe_constants import *
|
||||
from .path_utils import _model_roots, _resolve_exact_model_reference
|
||||
from .recipe_images import _attach_preview_snapshots
|
||||
from .utils import require_filename, resolve_within
|
||||
from .workflow_schema import (
|
||||
_node_title, _node_type, _parameter_signature, _validate_workflow,
|
||||
_widget_values, _workflow_fingerprint,
|
||||
)
|
||||
|
||||
def _model_reference_specs(node):
|
||||
"""Known loader adapters only; arbitrary third-party widgets stay parameters."""
|
||||
node_type = _node_type(node)
|
||||
lowered = node_type.lower()
|
||||
specs = []
|
||||
|
||||
if re.search(r"checkpointloader(simple)?$", lowered):
|
||||
specs.append((0, "checkpoint", "checkpoint"))
|
||||
elif lowered.endswith("unetloader"):
|
||||
specs.append((0, "unet", "unet"))
|
||||
elif re.search(r"loraloader", lowered):
|
||||
specs.append((0, "lora", "lora"))
|
||||
elif lowered.endswith("vaeloader"):
|
||||
specs.append((0, "vae", "vae"))
|
||||
elif lowered.endswith("clipvisionloader"):
|
||||
specs.append((0, "clip_vision", "clip_vision"))
|
||||
elif lowered.endswith("controlnetloader"):
|
||||
specs.append((0, "controlnet", "controlnet"))
|
||||
elif re.search(r"(?:^|[^a-z])(?:dual|triple)?cliploader$", lowered):
|
||||
specs.append((0, "text_encoder", "clip"))
|
||||
if "dualclip" in lowered or "tripleclip" in lowered:
|
||||
specs.append((1, "text_encoder", "clip"))
|
||||
if "tripleclip" in lowered:
|
||||
specs.append((2, "text_encoder", "clip"))
|
||||
return specs
|
||||
|
||||
|
||||
def _identity_for_reference(saved_value):
|
||||
resolved = _resolve_exact_model_reference(saved_value)
|
||||
if not resolved:
|
||||
return {"status": "unavailable"}, None
|
||||
|
||||
identity = {"status": "unverified", "provenance": "local cached metadata"}
|
||||
origin = None
|
||||
try:
|
||||
identity["size"] = os.path.getsize(resolved["path"])
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
metadata = get_metadata(resolved["path"])
|
||||
candidate_hash = str(metadata.get("hash") or "").strip()
|
||||
if SHA256_PATTERN.fullmatch(candidate_hash):
|
||||
identity["status"] = "verified"
|
||||
identity["sha256"] = candidate_hash.lower()
|
||||
identity["provenance"] = metadata.get("hash_source") or "sidecar file SHA-256"
|
||||
|
||||
civitai_url = metadata.get("civitai_url")
|
||||
if civitai_url:
|
||||
origin = {
|
||||
"provider": "civitai",
|
||||
"model_name": metadata.get("name") or "",
|
||||
"model_url": civitai_url,
|
||||
"model_id": metadata.get("model_id") or "",
|
||||
"version_id": metadata.get("version_id") or ""
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return identity, origin
|
||||
|
||||
|
||||
def _computed_identity_for_reference(saved_value):
|
||||
"""Compute an exact SHA-256 only after an explicit save-time opt-in."""
|
||||
resolved = _resolve_exact_model_reference(saved_value)
|
||||
if not resolved:
|
||||
return {"status": "unavailable"}
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with open(resolved["path"], "rb") as model_file:
|
||||
for block in iter(lambda: model_file.read(4 * 1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return {
|
||||
"status": "verified",
|
||||
"sha256": digest.hexdigest(),
|
||||
"size": os.path.getsize(resolved["path"]),
|
||||
"provenance": "computed during recipe save",
|
||||
}
|
||||
except OSError:
|
||||
return {"status": "unverified"}
|
||||
|
||||
|
||||
def _workflow_identity_for_reference(workflow, node_id, saved_value):
|
||||
extra = workflow.get("extra") if isinstance(workflow, dict) else None
|
||||
hashes = extra.get("anomalous_hashes") if isinstance(extra, dict) else None
|
||||
if not isinstance(hashes, dict) or not isinstance(saved_value, str):
|
||||
return None
|
||||
normalized = saved_value.replace("\\", "/")
|
||||
windows_path = saved_value.replace("/", "\\")
|
||||
keys = (
|
||||
f"{node_id}_{saved_value}",
|
||||
f"{node_id}_{normalized}",
|
||||
f"{node_id}_{windows_path}",
|
||||
saved_value,
|
||||
normalized,
|
||||
windows_path,
|
||||
)
|
||||
record = next((hashes[key] for key in keys if key in hashes), None)
|
||||
candidate_hash = record if isinstance(record, str) else record.get("hash") if isinstance(record, dict) else None
|
||||
if not isinstance(candidate_hash, str) or not SHA256_PATTERN.fullmatch(candidate_hash.strip()):
|
||||
return None
|
||||
identity = {
|
||||
"status": "verified",
|
||||
"sha256": candidate_hash.strip().lower(),
|
||||
"provenance": "workflow snapshot",
|
||||
}
|
||||
if isinstance(record, dict):
|
||||
try:
|
||||
identity["size"] = int(record.get("size"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return identity
|
||||
|
||||
|
||||
def _build_model_references(recipe, verify_identities=False):
|
||||
workflow = recipe.get("workflow") if isinstance(recipe, dict) else None
|
||||
params = recipe.get("params") if isinstance(recipe, dict) else None
|
||||
base_model = params.get("baseModel") if isinstance(params, dict) else None
|
||||
references = []
|
||||
for node in workflow.get("nodes", []) if isinstance(workflow, dict) else []:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
values = _widget_values(node)
|
||||
for widget_index, category, widget_name in _model_reference_specs(node):
|
||||
saved_value = values[widget_index] if widget_index < len(values) else None
|
||||
if not isinstance(saved_value, str) or not saved_value.strip():
|
||||
continue
|
||||
identity_result = _identity_for_reference(saved_value)
|
||||
if isinstance(identity_result, tuple):
|
||||
identity, origin = (identity_result + (None,))[:2]
|
||||
else:
|
||||
# Keep compatibility with older callers/tests that provide the
|
||||
# pre-origin helper contract and return identity only.
|
||||
identity, origin = identity_result, None
|
||||
workflow_identity = _workflow_identity_for_reference(workflow, node.get("id"), saved_value)
|
||||
if workflow_identity:
|
||||
identity = workflow_identity
|
||||
elif (
|
||||
verify_identities
|
||||
and category in VERIFIABLE_RECIPE_MODEL_CATEGORIES
|
||||
and identity.get("status") != "verified"
|
||||
):
|
||||
identity = _computed_identity_for_reference(saved_value)
|
||||
ref_dict = {
|
||||
"node_id": node.get("id"),
|
||||
"node_type": _node_type(node) or "Unknown",
|
||||
"node_title": _node_title(node),
|
||||
"widget_index": widget_index,
|
||||
"widget_name": widget_name,
|
||||
"saved_value": saved_value,
|
||||
"category": category,
|
||||
"base_model": base_model,
|
||||
"identity": identity,
|
||||
}
|
||||
if origin:
|
||||
ref_dict["origin"] = origin
|
||||
references.append(ref_dict)
|
||||
return references
|
||||
|
||||
|
||||
def _model_reference_key(reference):
|
||||
if not isinstance(reference, dict):
|
||||
return None
|
||||
return (
|
||||
reference.get("node_id"),
|
||||
reference.get("widget_index"),
|
||||
reference.get("category"),
|
||||
reference.get("saved_value"),
|
||||
)
|
||||
|
||||
|
||||
def _preserve_model_reference_fields(previous_references, references, preserve_identity=True):
|
||||
"""Carry package/history presentation and identity without trusting names."""
|
||||
previous = {
|
||||
_model_reference_key(reference): reference
|
||||
for reference in previous_references or []
|
||||
if _model_reference_key(reference) is not None
|
||||
and isinstance(reference, dict)
|
||||
}
|
||||
for reference in references:
|
||||
prior = previous.get(_model_reference_key(reference))
|
||||
if prior is None:
|
||||
continue
|
||||
for field in ("identity", "origin", "preview"):
|
||||
if field == "preview" or (field in ("identity", "origin") and preserve_identity):
|
||||
if isinstance(prior.get(field), dict):
|
||||
reference[field] = json.loads(json.dumps(prior[field], ensure_ascii=False))
|
||||
user_note = prior.get("user_note")
|
||||
if isinstance(user_note, str) and user_note.strip():
|
||||
reference["user_note"] = user_note.strip()
|
||||
|
||||
|
||||
def _enrich_recipe(
|
||||
recipe,
|
||||
recipes_dir=None,
|
||||
filename=None,
|
||||
recapture_previews=True,
|
||||
refresh_identities=False,
|
||||
verify_identities=False,
|
||||
):
|
||||
recipe["workflow_fingerprint"] = _workflow_fingerprint(recipe["workflow"])
|
||||
# Keep the exact parameter-match identity alongside the structural
|
||||
# fingerprint. The detail panel can use this value for result discovery
|
||||
# without asking the browser to recreate or upload a legacy notebook.
|
||||
recipe["parameter_signature"] = _parameter_signature(recipe["workflow"])
|
||||
params = dict(recipe.get("params") or {})
|
||||
previous_references = params.get("model_references", [])
|
||||
references = _build_model_references(recipe, verify_identities=verify_identities)
|
||||
_preserve_model_reference_fields(
|
||||
previous_references,
|
||||
references,
|
||||
preserve_identity=not (refresh_identities or verify_identities),
|
||||
)
|
||||
if recipes_dir and filename and recapture_previews:
|
||||
_attach_preview_snapshots(recipe, recipes_dir, filename, references)
|
||||
params["model_references"] = references
|
||||
recipe["params"] = params
|
||||
if recipe.get("workflow_scope") not in {"partial", "complete"}:
|
||||
recipe["workflow_scope"] = "complete"
|
||||
recipe["schema_version"] = 7
|
||||
encoded = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
if len(encoded) > MAX_RECIPE_BYTES:
|
||||
raise ValueError("Recipe is too large")
|
||||
return recipe
|
||||
|
||||
|
||||
def _normalise_source_image(value):
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, dict) or value.get("type") != "output":
|
||||
raise ValueError("Invalid source image")
|
||||
filename = require_filename(value.get("filename", ""))
|
||||
subfolder = value.get("subfolder", "")
|
||||
if not isinstance(subfolder, str) or len(subfolder) > MAX_SOURCE_SUBFOLDER_LENGTH:
|
||||
raise ValueError("Invalid source image")
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
resolve_within(output_dir, subfolder)
|
||||
return {
|
||||
"filename": filename,
|
||||
"subfolder": subfolder,
|
||||
"type": "output",
|
||||
}
|
||||
|
||||
|
||||
def _normalise_recipe(payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Invalid recipe")
|
||||
|
||||
name = payload.get("name", "")
|
||||
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_NAME_LENGTH:
|
||||
raise ValueError("Invalid recipe name")
|
||||
|
||||
raw_tags = payload.get("tags", [])
|
||||
if not isinstance(raw_tags, list):
|
||||
raise ValueError("Invalid recipe tags")
|
||||
tags = []
|
||||
for tag in raw_tags:
|
||||
if not isinstance(tag, str):
|
||||
raise ValueError("Invalid recipe tag")
|
||||
tag = tag.strip()
|
||||
if not tag or len(tag) > MAX_TAG_LENGTH:
|
||||
continue
|
||||
if tag not in tags:
|
||||
tags.append(tag)
|
||||
if len(tags) >= MAX_TAGS:
|
||||
break
|
||||
|
||||
notes = payload.get("notes", "")
|
||||
if not isinstance(notes, str) or len(notes) > MAX_NOTES_LENGTH:
|
||||
raise ValueError("Invalid recipe notes")
|
||||
|
||||
params = payload.get("params", {})
|
||||
workflow = payload.get("workflow")
|
||||
if not isinstance(params, dict) or not isinstance(workflow, dict):
|
||||
raise ValueError("Invalid recipe workflow")
|
||||
model_references = params.get("model_references")
|
||||
if model_references is not None:
|
||||
if not isinstance(model_references, list):
|
||||
raise ValueError("Invalid recipe model references")
|
||||
for reference in model_references:
|
||||
if not isinstance(reference, dict):
|
||||
raise ValueError("Invalid recipe model reference")
|
||||
user_note = reference.get("user_note")
|
||||
if user_note is None:
|
||||
continue
|
||||
if not isinstance(user_note, str) or len(user_note) > MAX_MODEL_NOTE_LENGTH:
|
||||
raise ValueError("Invalid recipe model note")
|
||||
if user_note.strip():
|
||||
reference["user_note"] = user_note.strip()
|
||||
else:
|
||||
reference.pop("user_note", None)
|
||||
_validate_workflow(workflow)
|
||||
workflow_scope = payload.get("workflow_scope", "complete")
|
||||
if workflow_scope not in {"partial", "complete"}:
|
||||
raise ValueError("Invalid recipe workflow scope")
|
||||
|
||||
thumbnail = payload.get("thumbnail")
|
||||
if thumbnail is not None:
|
||||
if (
|
||||
not isinstance(thumbnail, str)
|
||||
or len(thumbnail) > MAX_THUMBNAIL_LENGTH
|
||||
or not thumbnail.lower().startswith(SAFE_THUMBNAIL_PREFIXES)
|
||||
):
|
||||
thumbnail = None
|
||||
|
||||
presentation = payload.get("presentation", {})
|
||||
if presentation is None:
|
||||
presentation = {}
|
||||
if not isinstance(presentation, dict):
|
||||
raise ValueError("Invalid recipe presentation")
|
||||
save_model_preview_snapshots = presentation.get("save_model_preview_snapshots", True)
|
||||
if not isinstance(save_model_preview_snapshots, bool):
|
||||
raise ValueError("Invalid recipe presentation")
|
||||
cover_asset_id = presentation.get("cover_asset_id")
|
||||
if cover_asset_id is not None:
|
||||
cover_asset_id = require_filename(cover_asset_id)
|
||||
if not cover_asset_id.startswith("cover-") or not cover_asset_id.endswith(".webp"):
|
||||
raise ValueError("Invalid recipe presentation")
|
||||
|
||||
recipe = {
|
||||
"schema_version": 1,
|
||||
"name": name,
|
||||
"tags": tags,
|
||||
"notes": notes.strip(),
|
||||
"params": params,
|
||||
"workflow": workflow,
|
||||
"workflow_scope": workflow_scope,
|
||||
"thumbnail": thumbnail,
|
||||
"source_image": _normalise_source_image(payload.get("source_image")),
|
||||
"presentation": {
|
||||
"save_model_preview_snapshots": save_model_preview_snapshots,
|
||||
**({"cover_asset_id": cover_asset_id} if cover_asset_id else {}),
|
||||
},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}
|
||||
encoded = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
if len(encoded) > MAX_RECIPE_BYTES:
|
||||
raise ValueError("Recipe is too large")
|
||||
return recipe
|
||||
|
||||
|
||||
def _updated_recipe(payload, existing):
|
||||
recipe = _normalise_recipe(payload)
|
||||
existing_presentation = existing.get("presentation") if isinstance(existing.get("presentation"), dict) else {}
|
||||
existing_cover = existing_presentation.get("cover_asset_id")
|
||||
source_unchanged = recipe.get("source_image") == existing.get("source_image")
|
||||
if existing_cover and source_unchanged and not recipe["presentation"].get("cover_asset_id"):
|
||||
recipe["presentation"]["cover_asset_id"] = existing_cover
|
||||
created_timestamp = existing.get("created_timestamp", existing.get("timestamp"))
|
||||
if isinstance(created_timestamp, int):
|
||||
recipe["created_timestamp"] = created_timestamp
|
||||
recipe["updated_timestamp"] = recipe["timestamp"]
|
||||
return recipe
|
||||
@@ -0,0 +1,141 @@
|
||||
"""User-owned Workflow Recipe records and bounded history persistence."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import folder_paths
|
||||
|
||||
from .recipe_constants import MAX_HISTORY_VERSIONS
|
||||
from .recipe_images import _recipe_assets_dir
|
||||
from .utils import require_filename, resolve_within
|
||||
|
||||
def get_recipes_dir():
|
||||
"""Return the user-owned recipe directory, creating it if needed."""
|
||||
user_dir = (
|
||||
folder_paths.get_user_directory()
|
||||
if hasattr(folder_paths, "get_user_directory")
|
||||
else os.path.join(folder_paths.base_path, "user", "default")
|
||||
)
|
||||
recipes_dir = os.path.join(user_dir, "workflows", "anomalous_recipes")
|
||||
os.makedirs(recipes_dir, exist_ok=True)
|
||||
return os.path.realpath(recipes_dir)
|
||||
|
||||
|
||||
def _read_recipe(path):
|
||||
with open(path, "r", encoding="utf-8") as recipe_file:
|
||||
return json.load(recipe_file)
|
||||
|
||||
|
||||
def _list_recipes(recipes_dir):
|
||||
recipes = []
|
||||
try:
|
||||
with os.scandir(recipes_dir) as entries:
|
||||
for entry in entries:
|
||||
if not entry.is_file() or not entry.name.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
filename = require_filename(entry.name)
|
||||
data = _read_recipe(resolve_within(recipes_dir, filename))
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
# The graph can be much larger than all card data combined.
|
||||
summary = {key: value for key, value in data.items() if key != "workflow"}
|
||||
recipes.append({"filename": filename, "data": summary})
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
except OSError:
|
||||
return []
|
||||
recipes.sort(key=lambda item: item["data"].get("timestamp", 0), reverse=True)
|
||||
return recipes
|
||||
|
||||
|
||||
def _write_recipe(recipes_dir, filename, recipe):
|
||||
path = resolve_within(recipes_dir, filename)
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".recipe-", suffix=".tmp", dir=recipes_dir)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as recipe_file:
|
||||
json.dump(recipe, recipe_file, ensure_ascii=False, separators=(",", ":"))
|
||||
recipe_file.flush()
|
||||
os.fsync(recipe_file.fileno())
|
||||
os.replace(temp_path, path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _history_dir(recipes_dir, filename, create=False):
|
||||
"""Return the contained, user-data-only history directory for one recipe."""
|
||||
stem = os.path.splitext(require_filename(filename))[0]
|
||||
history_dir = resolve_within(recipes_dir, ".history", stem)
|
||||
if create:
|
||||
os.makedirs(history_dir, exist_ok=True)
|
||||
return history_dir
|
||||
|
||||
|
||||
def _archive_recipe(recipes_dir, filename, recipe):
|
||||
"""Atomically retain a bounded pre-update snapshot before replacing a recipe."""
|
||||
history_dir = _history_dir(recipes_dir, filename, create=True)
|
||||
version_name = f"version_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}.json"
|
||||
_write_recipe(history_dir, version_name, recipe)
|
||||
|
||||
entries = []
|
||||
with os.scandir(history_dir) as scan:
|
||||
for entry in scan:
|
||||
if entry.is_file() and entry.name.endswith(".json"):
|
||||
entries.append(entry)
|
||||
entries.sort(key=lambda entry: entry.stat().st_mtime_ns, reverse=True)
|
||||
for entry in entries[MAX_HISTORY_VERSIONS:]:
|
||||
try:
|
||||
os.remove(resolve_within(history_dir, entry.name))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _list_recipe_history(recipes_dir, filename):
|
||||
history_dir = _history_dir(recipes_dir, filename)
|
||||
versions = []
|
||||
try:
|
||||
with os.scandir(history_dir) as entries:
|
||||
for entry in entries:
|
||||
if not entry.is_file() or not entry.name.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
version = require_filename(entry.name)
|
||||
data = _read_recipe(resolve_within(history_dir, version))
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
versions.append({
|
||||
"version": version,
|
||||
"timestamp": data.get("timestamp", 0),
|
||||
"name": data.get("name", ""),
|
||||
"workflow_fingerprint": data.get("workflow_fingerprint"),
|
||||
"model_reference_count": len(
|
||||
(data.get("params") or {}).get("model_references", [])
|
||||
if isinstance(data.get("params"), dict)
|
||||
else []
|
||||
),
|
||||
})
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
except OSError:
|
||||
return []
|
||||
versions.sort(key=lambda item: item["timestamp"], reverse=True)
|
||||
return versions
|
||||
|
||||
|
||||
def _delete_recipe_with_history(recipes_dir, filename):
|
||||
"""Delete an explicitly selected recipe and its contained local history."""
|
||||
os.remove(resolve_within(recipes_dir, filename))
|
||||
history_dir = _history_dir(recipes_dir, filename)
|
||||
if os.path.isdir(history_dir):
|
||||
shutil.rmtree(history_dir)
|
||||
assets_dir = _recipe_assets_dir(recipes_dir, filename)
|
||||
if os.path.isdir(assets_dir):
|
||||
shutil.rmtree(assets_dir)
|
||||
@@ -0,0 +1,474 @@
|
||||
"""HTTP routes for Workflow Recipes."""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from . import recipe_schema as _recipe_schema_module
|
||||
from .recipe_constants import *
|
||||
from .recipe_images import (
|
||||
_attach_preview_snapshots, _bounded_gallery_value, _decode_embedded_json,
|
||||
_embedded_parameter_signature, _embedded_workflow_node_signature,
|
||||
_embedded_workflow_payload, _gallery_parameter_diff, _output_source_path,
|
||||
_parameter_gallery_images, _recipe_assets_dir, _recipe_gallery_images,
|
||||
_recipe_cover_webp_bytes, _recent_output_pngs, _store_recipe_gallery_cover,
|
||||
_thumbnail_webp_bytes,
|
||||
)
|
||||
from .recipe_schema import (
|
||||
_build_model_references,
|
||||
_computed_identity_for_reference as _schema_computed_identity_for_reference,
|
||||
_enrich_recipe as _schema_enrich_recipe, _normalise_recipe, _normalise_source_image,
|
||||
_identity_for_reference as _schema_identity_for_reference,
|
||||
_model_reference_key, _model_reference_specs,
|
||||
_model_roots, _preserve_model_reference_fields,
|
||||
_resolve_exact_model_reference, _updated_recipe, _workflow_identity_for_reference,
|
||||
)
|
||||
from .recipe_store import (
|
||||
_archive_recipe, _delete_recipe_with_history, _history_dir, _list_recipe_history,
|
||||
_list_recipes, _read_recipe, _write_recipe, get_recipes_dir,
|
||||
)
|
||||
from .utils import require_filename, resolve_within
|
||||
from .workflow_schema import (
|
||||
_clean_volatile_params, _node_title, _node_type, _normalise_workflow_key,
|
||||
_parameter_signature, _recipe_receipt, _validate_workflow,
|
||||
_volatile_widget_indexes, _widget_values, _workflow_fingerprint,
|
||||
_workflow_link_records, _workflow_node_key, _workflow_node_signature,
|
||||
_workflow_node_types,
|
||||
)
|
||||
|
||||
|
||||
def _computed_identity_for_reference(saved_value):
|
||||
"""Compatibility facade for callers that patched the former route module."""
|
||||
previous = _recipe_schema_module._resolve_exact_model_reference
|
||||
_recipe_schema_module._resolve_exact_model_reference = _resolve_exact_model_reference
|
||||
try:
|
||||
return _schema_computed_identity_for_reference(saved_value)
|
||||
finally:
|
||||
_recipe_schema_module._resolve_exact_model_reference = previous
|
||||
|
||||
|
||||
def _identity_for_reference(saved_value):
|
||||
"""Compatibility facade for the former monolithic recipes module."""
|
||||
previous = _recipe_schema_module._resolve_exact_model_reference
|
||||
_recipe_schema_module._resolve_exact_model_reference = _resolve_exact_model_reference
|
||||
try:
|
||||
return _schema_identity_for_reference(saved_value)
|
||||
finally:
|
||||
_recipe_schema_module._resolve_exact_model_reference = previous
|
||||
|
||||
|
||||
def _enrich_recipe(*args, **kwargs):
|
||||
"""Compatibility facade while domain callers migrate to recipe_schema."""
|
||||
previous = _recipe_schema_module._identity_for_reference
|
||||
_recipe_schema_module._identity_for_reference = _identity_for_reference
|
||||
try:
|
||||
return _schema_enrich_recipe(*args, **kwargs)
|
||||
finally:
|
||||
_recipe_schema_module._identity_for_reference = previous
|
||||
|
||||
async def api_get_recipes(request):
|
||||
recipes_dir = get_recipes_dir()
|
||||
recipes = await asyncio.to_thread(_list_recipes, recipes_dir)
|
||||
return web.json_response({"recipes": recipes})
|
||||
|
||||
|
||||
async def api_save_recipe(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
verify_identities = payload.get("verify_model_identities", False)
|
||||
if not isinstance(verify_identities, bool):
|
||||
raise ValueError("Invalid verification preference")
|
||||
recipe = _normalise_recipe(payload)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
|
||||
except Exception:
|
||||
return web.json_response({"status": "error", "message": "Invalid request"}, status=400)
|
||||
|
||||
filename = f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
|
||||
try:
|
||||
recipes_dir = get_recipes_dir()
|
||||
recipe["created_timestamp"] = recipe["timestamp"]
|
||||
recipe["updated_timestamp"] = recipe["timestamp"]
|
||||
recipe = await asyncio.to_thread(
|
||||
_enrich_recipe,
|
||||
recipe,
|
||||
recipes_dir,
|
||||
filename,
|
||||
True,
|
||||
False,
|
||||
verify_identities,
|
||||
)
|
||||
await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe)
|
||||
except (OSError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Could not save recipe"}, status=500)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"filename": filename,
|
||||
"receipt": _recipe_receipt(recipe, filename),
|
||||
})
|
||||
|
||||
|
||||
async def api_delete_recipe(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid filename")
|
||||
recipes_dir = get_recipes_dir()
|
||||
path = resolve_within(recipes_dir, filename)
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
|
||||
except Exception:
|
||||
return web.json_response({"status": "error", "message": "Invalid request"}, status=400)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_delete_recipe_with_history, recipes_dir, filename)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not delete recipe"}, status=500)
|
||||
return web.json_response({"status": "success"})
|
||||
|
||||
|
||||
async def api_get_recipe_full(request):
|
||||
try:
|
||||
filename = require_filename(request.query.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid filename")
|
||||
recipes_dir = get_recipes_dir()
|
||||
path = resolve_within(recipes_dir, filename)
|
||||
data = await asyncio.to_thread(_read_recipe, path)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("workflow"), dict):
|
||||
raise ValueError("Invalid recipe")
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read recipe"}, status=500)
|
||||
return web.json_response({"status": "success", "data": data})
|
||||
|
||||
|
||||
async def api_get_recipe_asset(request):
|
||||
try:
|
||||
filename = require_filename(request.query.get("filename", ""))
|
||||
asset_id = require_filename(request.query.get("asset", ""))
|
||||
if not filename.endswith(".json") or not asset_id.endswith(".webp"):
|
||||
raise ValueError("Invalid recipe asset")
|
||||
recipes_dir = get_recipes_dir()
|
||||
await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
||||
asset_path = resolve_within(_recipe_assets_dir(recipes_dir, filename), asset_id)
|
||||
if not os.path.isfile(asset_path):
|
||||
raise FileNotFoundError
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe asset"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read recipe asset"}, status=500)
|
||||
return web.FileResponse(asset_path)
|
||||
|
||||
|
||||
async def api_get_recipe_gallery(request):
|
||||
"""Find recent output PNGs whose embedded node composition matches one recipe."""
|
||||
try:
|
||||
filename = request.query.get("filename")
|
||||
fingerprint = request.query.get("fingerprint")
|
||||
if filename:
|
||||
filename = require_filename(filename)
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid recipe")
|
||||
recipes_dir = get_recipes_dir()
|
||||
recipe = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
||||
if not isinstance(recipe, dict) or not isinstance(recipe.get("workflow"), dict):
|
||||
raise ValueError("Invalid recipe")
|
||||
fingerprint = _workflow_node_signature(recipe["workflow"])["value"]
|
||||
elif not isinstance(fingerprint, str) or not SHA256_PATTERN.fullmatch(fingerprint):
|
||||
raise ValueError("Invalid fingerprint")
|
||||
images, scanned = await asyncio.to_thread(_recipe_gallery_images, fingerprint.lower())
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe gallery request"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read recipe gallery"}, status=500)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"fingerprint": fingerprint.lower(),
|
||||
"match_mode": "node-types",
|
||||
"images": images,
|
||||
"scanned": scanned,
|
||||
})
|
||||
|
||||
|
||||
async def api_get_recipe_parameter_gallery(request):
|
||||
"""Find output PNGs with the same saved node parameters as one recipe."""
|
||||
try:
|
||||
filename = require_filename(request.query.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid recipe")
|
||||
recipes_dir = get_recipes_dir()
|
||||
recipe = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
||||
if not isinstance(recipe, dict) or not isinstance(recipe.get("workflow"), dict):
|
||||
raise ValueError("Invalid recipe")
|
||||
signature = _parameter_signature(recipe["workflow"])["value"]
|
||||
images, scanned = await asyncio.to_thread(_parameter_gallery_images, signature.lower())
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe parameter gallery request"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read recipe parameter gallery"}, status=500)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"fingerprint": signature.lower(),
|
||||
"match_mode": "parameters",
|
||||
"images": images,
|
||||
"scanned": scanned,
|
||||
})
|
||||
|
||||
|
||||
async def api_get_recipe_gallery_compare(request):
|
||||
"""Return bounded parameters and differences for one matched output image."""
|
||||
try:
|
||||
filename = require_filename(request.query.get("filename", ""))
|
||||
source_image = _normalise_source_image({
|
||||
"type": "output",
|
||||
"filename": request.query.get("image_filename", ""),
|
||||
"subfolder": request.query.get("image_subfolder", ""),
|
||||
})
|
||||
recipes_dir = get_recipes_dir()
|
||||
recipe = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
||||
image_path = await asyncio.to_thread(_output_source_path, source_image)
|
||||
payload = await asyncio.to_thread(_embedded_workflow_payload, image_path)
|
||||
if not payload or not (payload.get("workflow") or payload.get("prompt")):
|
||||
raise ValueError("Image has no workflow metadata")
|
||||
comparison = _gallery_parameter_diff(recipe.get("workflow"), payload)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"image": source_image,
|
||||
"match_mode": "node-types",
|
||||
"comparison": comparison,
|
||||
})
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid gallery comparison request"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Image or recipe not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read gallery comparison"}, status=500)
|
||||
|
||||
|
||||
async def api_set_recipe_gallery_cover(request):
|
||||
"""Promote one verified output image to a portable recipe cover asset."""
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid filename")
|
||||
source_image = _normalise_source_image(payload.get("source_image"))
|
||||
recipes_dir = get_recipes_dir()
|
||||
existing = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
||||
if not isinstance(existing, dict) or not isinstance(existing.get("workflow"), dict):
|
||||
raise ValueError("Invalid recipe")
|
||||
cover = await asyncio.to_thread(_store_recipe_gallery_cover, recipes_dir, filename, source_image)
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe cover"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Output image not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not create recipe cover"}, status=500)
|
||||
|
||||
recipe = copy.deepcopy(existing)
|
||||
recipe["source_image"] = source_image
|
||||
recipe["thumbnail"] = None
|
||||
presentation = dict(recipe.get("presentation") or {})
|
||||
presentation["cover_asset_id"] = cover["asset_id"]
|
||||
recipe["presentation"] = presentation
|
||||
recipe["timestamp"] = int(time.time() * 1000)
|
||||
recipe["updated_timestamp"] = recipe["timestamp"]
|
||||
recipe["workflow_fingerprint"] = _workflow_fingerprint(recipe["workflow"])
|
||||
if recipe.get("workflow_scope") not in {"partial", "complete"}:
|
||||
recipe["workflow_scope"] = "complete"
|
||||
recipe["schema_version"] = max(7, int(recipe.get("schema_version") or 1))
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_archive_recipe, recipes_dir, filename, existing)
|
||||
await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not update recipe cover"}, status=500)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"filename": filename,
|
||||
"cover": cover,
|
||||
"source_image": source_image,
|
||||
"workflow_fingerprint": recipe["workflow_fingerprint"],
|
||||
})
|
||||
|
||||
|
||||
async def api_update_recipe(request):
|
||||
"""Replace one recipe while preserving its prior state in local history."""
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid filename")
|
||||
recipes_dir = get_recipes_dir()
|
||||
path = resolve_within(recipes_dir, filename)
|
||||
existing = await asyncio.to_thread(_read_recipe, path)
|
||||
if not isinstance(existing, dict) or not isinstance(existing.get("workflow"), dict):
|
||||
raise ValueError("Invalid recipe")
|
||||
refresh_identities = bool(payload.get("refreshIdentities"))
|
||||
verify_identities = payload.get("verify_model_identities", False)
|
||||
if not isinstance(verify_identities, bool):
|
||||
raise ValueError("Invalid verification preference")
|
||||
refresh_only = refresh_identities and set(payload).issubset({"filename", "refreshIdentities"})
|
||||
recipe = copy.deepcopy(existing) if refresh_only else _updated_recipe(payload, existing)
|
||||
recipe = await asyncio.to_thread(
|
||||
_enrich_recipe,
|
||||
recipe,
|
||||
recipes_dir,
|
||||
filename,
|
||||
True,
|
||||
refresh_identities,
|
||||
verify_identities,
|
||||
)
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read recipe"}, status=500)
|
||||
except Exception:
|
||||
return web.json_response({"status": "error", "message": "Invalid request"}, status=400)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_archive_recipe, recipes_dir, filename, existing)
|
||||
await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not update recipe"}, status=500)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"filename": filename,
|
||||
"receipt": _recipe_receipt(recipe, filename),
|
||||
})
|
||||
|
||||
|
||||
async def api_get_recipe_history(request):
|
||||
try:
|
||||
filename = require_filename(request.query.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid filename")
|
||||
recipes_dir = get_recipes_dir()
|
||||
# Verify the root recipe exists before exposing its history directory.
|
||||
await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
||||
versions = await asyncio.to_thread(_list_recipe_history, recipes_dir, filename)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read recipe history"}, status=500)
|
||||
return web.json_response({"status": "success", "versions": versions})
|
||||
|
||||
|
||||
async def api_get_recipe_version(request):
|
||||
"""Return one bounded historical recipe for semantic comparison only."""
|
||||
try:
|
||||
filename = require_filename(request.query.get("filename", ""))
|
||||
version = require_filename(request.query.get("version", ""))
|
||||
if not filename.endswith(".json") or not version.endswith(".json"):
|
||||
raise ValueError("Invalid recipe version")
|
||||
recipes_dir = get_recipes_dir()
|
||||
await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
||||
data = await asyncio.to_thread(
|
||||
_read_recipe,
|
||||
resolve_within(_history_dir(recipes_dir, filename), version),
|
||||
)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("workflow"), dict):
|
||||
raise ValueError("Invalid recipe version")
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe version"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Recipe version not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read recipe version"}, status=500)
|
||||
return web.json_response({"status": "success", "data": data})
|
||||
|
||||
|
||||
async def api_restore_recipe_version(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
version = require_filename(payload.get("version", ""))
|
||||
if not filename.endswith(".json") or not version.endswith(".json"):
|
||||
raise ValueError("Invalid filename")
|
||||
recipes_dir = get_recipes_dir()
|
||||
existing = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
||||
historical = await asyncio.to_thread(
|
||||
_read_recipe,
|
||||
resolve_within(_history_dir(recipes_dir, filename), version),
|
||||
)
|
||||
if not isinstance(existing, dict) or not isinstance(historical, dict):
|
||||
raise ValueError("Invalid recipe")
|
||||
recipe = _updated_recipe(historical, existing)
|
||||
recipe = await asyncio.to_thread(_enrich_recipe, recipe, recipes_dir, filename, False)
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not restore recipe history"}, status=500)
|
||||
except Exception:
|
||||
return web.json_response({"status": "error", "message": "Invalid request"}, status=400)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_archive_recipe, recipes_dir, filename, existing)
|
||||
await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not restore recipe history"}, status=500)
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"filename": filename,
|
||||
"receipt": _recipe_receipt(recipe, filename),
|
||||
})
|
||||
|
||||
|
||||
async def api_refresh_recipe_identity(request):
|
||||
"""Check exact saved model references using cached metadata only.
|
||||
|
||||
This deliberately does not walk model folders and never computes a full-file
|
||||
hash. The response is transient current-machine availability, separate from
|
||||
the historical identity stored in the recipe.
|
||||
"""
|
||||
try:
|
||||
payload = await request.json()
|
||||
references = payload.get("references", [])
|
||||
if not isinstance(references, list):
|
||||
raise ValueError("Invalid references")
|
||||
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid references"}, status=400)
|
||||
|
||||
results = []
|
||||
for reference in references[:128]:
|
||||
if not isinstance(reference, dict):
|
||||
continue
|
||||
saved_value = reference.get("saved_value")
|
||||
resolved = _resolve_exact_model_reference(saved_value)
|
||||
result = {
|
||||
"node_id": reference.get("node_id"),
|
||||
"widget_index": reference.get("widget_index"),
|
||||
"saved_value": saved_value,
|
||||
"availability": "available" if resolved else "missing",
|
||||
}
|
||||
if resolved:
|
||||
result["local_path"] = resolved["path"]
|
||||
identity_result = _identity_for_reference(saved_value)
|
||||
result["identity"] = identity_result[0] if isinstance(identity_result, tuple) else identity_result
|
||||
results.append(result)
|
||||
return web.json_response({"status": "success", "results": results})
|
||||
@@ -0,0 +1,601 @@
|
||||
from .metadata import get_metadata
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import time
|
||||
import uuid
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
from .folder_types import get_active_folder_types, get_active_scan_paths
|
||||
from .path_utils import resolve_folder_subdir
|
||||
try:
|
||||
from ..model_policies import is_physical_rename_protected
|
||||
except ImportError:
|
||||
from model_policies import is_physical_rename_protected
|
||||
|
||||
|
||||
SCAN_SESSION_ID = uuid.uuid4().hex
|
||||
SCAN_MARKER_LOCK = threading.Lock()
|
||||
ACTIVE_SCAN_MARKERS = set()
|
||||
SCAN_RUNTIME_STATE = {}
|
||||
|
||||
|
||||
def _read_json_file(path):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
value = json.load(f)
|
||||
return value if isinstance(value, dict) else None
|
||||
except (OSError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _remove_file(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _pid_is_running(pid):
|
||||
try:
|
||||
pid = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if pid <= 0:
|
||||
return False
|
||||
if pid == os.getpid():
|
||||
return True
|
||||
if os.name == 'nt':
|
||||
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
open_process = kernel32.OpenProcess
|
||||
open_process.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
||||
open_process.restype = wintypes.HANDLE
|
||||
get_exit_code = kernel32.GetExitCodeProcess
|
||||
get_exit_code.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
|
||||
get_exit_code.restype = wintypes.BOOL
|
||||
close_handle = kernel32.CloseHandle
|
||||
close_handle.argtypes = (wintypes.HANDLE,)
|
||||
close_handle.restype = wintypes.BOOL
|
||||
process = open_process(0x1000, False, pid)
|
||||
if not process:
|
||||
return False
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
return bool(get_exit_code(process, ctypes.byref(exit_code))) and exit_code.value == 259
|
||||
finally:
|
||||
close_handle(process)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _write_marker(marker_file, marker):
|
||||
temp_file = f"{marker_file}.{os.getpid()}.{threading.get_ident()}.tmp"
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(marker, f)
|
||||
os.replace(temp_file, marker_file)
|
||||
|
||||
|
||||
def _marker_owner_is_running(marker):
|
||||
if not marker:
|
||||
return False
|
||||
if marker.get("session_id") == SCAN_SESSION_ID:
|
||||
return marker.get("marker_file") in ACTIVE_SCAN_MARKERS
|
||||
worker_pid = marker.get("worker_pid")
|
||||
if worker_pid:
|
||||
return _pid_is_running(worker_pid)
|
||||
return _pid_is_running(marker.get("owner_pid"))
|
||||
|
||||
|
||||
def _cleanup_scan_artifacts(marker_file, artifact_files=()):
|
||||
_remove_file(marker_file)
|
||||
for path in artifact_files:
|
||||
_remove_file(path)
|
||||
|
||||
|
||||
def _claim_scan_marker(marker_file, kind, artifact_files=()):
|
||||
recovered = False
|
||||
with SCAN_MARKER_LOCK:
|
||||
if marker_file in ACTIVE_SCAN_MARKERS:
|
||||
return False, False
|
||||
if os.path.exists(marker_file):
|
||||
marker = _read_json_file(marker_file)
|
||||
if _marker_owner_is_running(marker):
|
||||
return False, False
|
||||
_cleanup_scan_artifacts(marker_file, artifact_files)
|
||||
recovered = True
|
||||
|
||||
marker = {
|
||||
"version": 2,
|
||||
"job_id": uuid.uuid4().hex,
|
||||
"kind": kind,
|
||||
"session_id": SCAN_SESSION_ID,
|
||||
"owner_pid": os.getpid(),
|
||||
"worker_pid": 0,
|
||||
"started_at": time.time(),
|
||||
"marker_file": marker_file,
|
||||
}
|
||||
try:
|
||||
with open(marker_file, 'x', encoding='utf-8') as f:
|
||||
json.dump(marker, f)
|
||||
except FileExistsError:
|
||||
return False, recovered
|
||||
ACTIVE_SCAN_MARKERS.add(marker_file)
|
||||
SCAN_RUNTIME_STATE[marker_file] = {
|
||||
"phase": "preparing",
|
||||
"error": "",
|
||||
"recovered": recovered,
|
||||
}
|
||||
return True, recovered
|
||||
|
||||
|
||||
def _update_scan_marker(marker_file, **values):
|
||||
with SCAN_MARKER_LOCK:
|
||||
if marker_file not in ACTIVE_SCAN_MARKERS:
|
||||
return
|
||||
marker = _read_json_file(marker_file) or {}
|
||||
marker.update(values)
|
||||
marker["marker_file"] = marker_file
|
||||
_write_marker(marker_file, marker)
|
||||
|
||||
|
||||
def _update_scan_state(marker_file, **values):
|
||||
with SCAN_MARKER_LOCK:
|
||||
if marker_file in ACTIVE_SCAN_MARKERS:
|
||||
SCAN_RUNTIME_STATE.setdefault(marker_file, {}).update(values)
|
||||
|
||||
|
||||
def _release_scan_marker(marker_file, artifact_files=()):
|
||||
with SCAN_MARKER_LOCK:
|
||||
ACTIVE_SCAN_MARKERS.discard(marker_file)
|
||||
SCAN_RUNTIME_STATE.pop(marker_file, None)
|
||||
_cleanup_scan_artifacts(marker_file, artifact_files)
|
||||
|
||||
|
||||
def _scan_marker_status(marker_file, artifact_files=()):
|
||||
with SCAN_MARKER_LOCK:
|
||||
if marker_file in ACTIVE_SCAN_MARKERS:
|
||||
return True, False, _read_json_file(marker_file) or {}
|
||||
if not os.path.exists(marker_file):
|
||||
return False, False, {}
|
||||
marker = _read_json_file(marker_file)
|
||||
if _marker_owner_is_running(marker):
|
||||
return True, False, marker or {}
|
||||
_cleanup_scan_artifacts(marker_file, artifact_files)
|
||||
return False, True, marker or {}
|
||||
|
||||
|
||||
def _scan_status_payload(marker_file, progress_file, artifact_files=()):
|
||||
scanning, interrupted, marker = _scan_marker_status(marker_file, artifact_files)
|
||||
data = {
|
||||
"scanning": scanning,
|
||||
"interrupted": interrupted,
|
||||
"job_id": marker.get("job_id", ""),
|
||||
"phase": "idle",
|
||||
"total": 0,
|
||||
"current": 0,
|
||||
"filename": "",
|
||||
}
|
||||
if scanning:
|
||||
with SCAN_MARKER_LOCK:
|
||||
data.update(SCAN_RUNTIME_STATE.get(marker_file, {}))
|
||||
progress = _read_json_file(progress_file)
|
||||
if progress:
|
||||
data.update(progress)
|
||||
return data
|
||||
|
||||
|
||||
def _protected_type_for_path(folder_path):
|
||||
"""Resolve protected registered aliases for a global physical-folder scan."""
|
||||
target = os.path.realpath(folder_path)
|
||||
for folder_type in folder_paths.folder_names_and_paths.keys():
|
||||
if not is_physical_rename_protected(folder_type=folder_type):
|
||||
continue
|
||||
try:
|
||||
if any(os.path.realpath(path) == target for path in folder_paths.get_folder_paths(folder_type)):
|
||||
return folder_type
|
||||
except Exception:
|
||||
continue
|
||||
return ""
|
||||
|
||||
async def api_scan_status(request):
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
subfolder = request.query.get('subfolder', '/')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
return web.json_response({"scanning": False})
|
||||
if not paths or path_idx < 0 or path_idx >= len(paths):
|
||||
return web.json_response({"scanning": False})
|
||||
try:
|
||||
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
except ValueError:
|
||||
return web.json_response({"scanning": False})
|
||||
|
||||
marker_file = os.path.join(target_dir, '.scan_in_progress')
|
||||
progress_file = os.path.join(target_dir, '.scan_progress.json')
|
||||
targets_file = os.path.join(target_dir, '.scan_targets.json')
|
||||
result_file = os.path.join(target_dir, '.scan_result.json')
|
||||
data = _scan_status_payload(marker_file, progress_file, (progress_file, targets_file))
|
||||
|
||||
if not data["scanning"] and os.path.exists(result_file):
|
||||
try:
|
||||
with open(result_file, 'r', encoding='utf-8') as f:
|
||||
data["result"] = __import__('json').load(f)
|
||||
os.remove(result_file)
|
||||
except:
|
||||
pass
|
||||
|
||||
return web.json_response(data)
|
||||
|
||||
async def api_scan_folder(request):
|
||||
"""Launches the scraper in the background for a specific directory."""
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
subfolder = request.query.get('subfolder', '/')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
|
||||
try:
|
||||
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
except (ValueError, KeyError):
|
||||
return web.json_response({"status": "error", "message": "Invalid folder type"})
|
||||
|
||||
if not os.path.exists(target_dir):
|
||||
return web.json_response({"status": "error", "message": "Directory does not exist"})
|
||||
|
||||
# Get the scraper path relative to this script
|
||||
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
scraper_path = os.path.join(plugin_dir, "scraper.py")
|
||||
|
||||
if not os.path.exists(scraper_path):
|
||||
return web.json_response({"status": "error", "message": "scraper.py not found in extension directory"})
|
||||
|
||||
print(f"[Anomalous Browser] Starting background scan for: {target_dir}")
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
data = None
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
|
||||
|
||||
offline_only = data.get("offline_only", False)
|
||||
skip_rename = data.get("skip_rename", False)
|
||||
virtual_rename = data.get("virtual_rename", False)
|
||||
physical_rename_requested = data.get("physical_rename", False)
|
||||
physical_rename = physical_rename_requested and not is_physical_rename_protected(
|
||||
folder_type=folder_type,
|
||||
folder_path=target_dir,
|
||||
)
|
||||
force_overwrite = data.get("force_overwrite", False)
|
||||
|
||||
target_files_list = data.get("target_files", [])
|
||||
if not target_files_list:
|
||||
target_files_str = request.query.get('target_files', '')
|
||||
target_files_list = [f.strip() for f in target_files_str.split(',')] if target_files_str else []
|
||||
|
||||
claimed = False
|
||||
try:
|
||||
marker_file = os.path.join(target_dir, '.scan_in_progress')
|
||||
progress_file = os.path.join(target_dir, '.scan_progress.json')
|
||||
targets_file = os.path.join(target_dir, '.scan_targets.json')
|
||||
claimed, recovered = _claim_scan_marker(
|
||||
marker_file,
|
||||
"folder",
|
||||
(progress_file, targets_file),
|
||||
)
|
||||
if not claimed:
|
||||
return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409)
|
||||
|
||||
if target_files_list:
|
||||
with open(targets_file, 'w', encoding='utf-8') as f:
|
||||
__import__('json').dump(target_files_list, f)
|
||||
|
||||
def run_bg():
|
||||
try:
|
||||
cmd = [sys.executable, scraper_path, target_dir, "--folder-type", folder_type]
|
||||
if offline_only:
|
||||
cmd.append("--offline-only")
|
||||
if skip_rename:
|
||||
cmd.append("--skip-rename")
|
||||
if virtual_rename:
|
||||
cmd.append("--virtual-rename")
|
||||
if physical_rename:
|
||||
cmd.append("--physical-rename")
|
||||
if force_overwrite:
|
||||
cmd.append("--force-overwrite")
|
||||
|
||||
cmd.extend(["--progress-file", progress_file])
|
||||
process = subprocess.Popen(cmd, cwd=plugin_dir)
|
||||
_update_scan_marker(marker_file, worker_pid=process.pid)
|
||||
return_code = process.wait()
|
||||
if return_code != 0:
|
||||
result_file = os.path.join(target_dir, '.scan_result.json')
|
||||
with open(result_file, 'w', encoding='utf-8') as f:
|
||||
json.dump({"success": 0, "fail": 1, "error": f"Scanner exited with code {return_code}"}, f)
|
||||
finally:
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
try: folder_paths.filename_list_cache.clear()
|
||||
except: pass
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
try: folder_paths.cache_helper.clear()
|
||||
except: pass
|
||||
|
||||
_release_scan_marker(marker_file, (progress_file, targets_file))
|
||||
|
||||
threading.Thread(target=run_bg, daemon=True).start()
|
||||
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"message": "Scan started in background. Check console for details.",
|
||||
"recovered": recovered,
|
||||
})
|
||||
except Exception as e:
|
||||
if claimed:
|
||||
_release_scan_marker(marker_file, (progress_file, targets_file))
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_scan_all(request):
|
||||
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
scraper_path = os.path.join(plugin_dir, "scraper.py")
|
||||
marker_file = os.path.join(plugin_dir, '.global_scan_in_progress')
|
||||
progress_file = os.path.join(plugin_dir, '.global_scan_progress.json')
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
data = None
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
|
||||
|
||||
offline_only = data.get("offline_only", False)
|
||||
use_local_metadata = data.get("use_local_metadata", True)
|
||||
skip_rename = data.get("skip_rename", True)
|
||||
virtual_rename = data.get("virtual_rename", False)
|
||||
physical_rename = data.get("physical_rename", False)
|
||||
force_overwrite = data.get("force_overwrite", False)
|
||||
skip_media = data.get("skip_media", False)
|
||||
|
||||
claimed = False
|
||||
try:
|
||||
claimed, recovered = _claim_scan_marker(marker_file, "global", (progress_file,))
|
||||
if not claimed:
|
||||
return web.json_response({"status": "error", "message": "Global scan already in progress"}, status=409)
|
||||
|
||||
def run_global_bg():
|
||||
try:
|
||||
paths_to_scan = get_active_scan_paths()
|
||||
paths_to_scan = [path for path in paths_to_scan if os.path.exists(path)]
|
||||
_update_scan_state(
|
||||
marker_file,
|
||||
phase="preparing",
|
||||
folder_total=len(paths_to_scan),
|
||||
folder_current=0,
|
||||
folder="",
|
||||
)
|
||||
for folder_index, base_dir in enumerate(paths_to_scan, 1):
|
||||
if not os.path.exists(base_dir): continue
|
||||
try:
|
||||
print(f"[Anomalous Browser] Global scan processing: {base_dir}")
|
||||
_remove_file(progress_file)
|
||||
_update_scan_state(
|
||||
marker_file,
|
||||
phase="enumerating",
|
||||
folder_total=len(paths_to_scan),
|
||||
folder_current=folder_index,
|
||||
folder=os.path.basename(os.path.normpath(base_dir)) or base_dir,
|
||||
error="",
|
||||
)
|
||||
cmd = [sys.executable, scraper_path, base_dir]
|
||||
protected_type = _protected_type_for_path(base_dir)
|
||||
if protected_type:
|
||||
cmd.extend(["--folder-type", protected_type])
|
||||
if offline_only:
|
||||
cmd.append("--offline-only")
|
||||
if skip_rename:
|
||||
cmd.append("--skip-rename")
|
||||
if virtual_rename:
|
||||
cmd.append("--virtual-rename")
|
||||
if physical_rename and not is_physical_rename_protected(
|
||||
folder_type=protected_type,
|
||||
folder_path=base_dir,
|
||||
):
|
||||
cmd.append("--physical-rename")
|
||||
if force_overwrite:
|
||||
cmd.append("--force-overwrite")
|
||||
if skip_media:
|
||||
cmd.append("--skip-media")
|
||||
if not use_local_metadata:
|
||||
cmd.append("--skip-local-metadata")
|
||||
cmd.extend(["--progress-file", progress_file])
|
||||
process = subprocess.Popen(cmd, cwd=plugin_dir)
|
||||
_update_scan_marker(marker_file, worker_pid=process.pid)
|
||||
return_code = process.wait()
|
||||
_update_scan_marker(marker_file, worker_pid=0)
|
||||
if return_code != 0:
|
||||
message = f"Scanner exited with code {return_code}: {base_dir}"
|
||||
_update_scan_state(marker_file, error=message)
|
||||
print(f"[Anomalous Browser] {message}")
|
||||
except Exception as e:
|
||||
_update_scan_state(marker_file, error=str(e))
|
||||
print(f"[Anomalous Browser] Global scan error on {base_dir}: {e}")
|
||||
finally:
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
try: folder_paths.filename_list_cache.clear()
|
||||
except: pass
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
try: folder_paths.cache_helper.clear()
|
||||
except: pass
|
||||
|
||||
_release_scan_marker(marker_file, (progress_file,))
|
||||
|
||||
threading.Thread(target=run_global_bg, daemon=True).start()
|
||||
return web.json_response({"status": "ok", "message": "Global scan started", "recovered": recovered})
|
||||
except Exception as e:
|
||||
if claimed:
|
||||
_release_scan_marker(marker_file, (progress_file,))
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_global_scan_status(request):
|
||||
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
marker_file = os.path.join(plugin_dir, '.global_scan_in_progress')
|
||||
progress_file = os.path.join(plugin_dir, '.global_scan_progress.json')
|
||||
return web.json_response(_scan_status_payload(marker_file, progress_file, (progress_file,)))
|
||||
async def api_clean_civitai_info(request):
|
||||
try:
|
||||
paths_to_clean = get_active_scan_paths()
|
||||
deleted_count = 0
|
||||
for base_dir in paths_to_clean:
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.endswith('.civitai.info'):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
os.remove(file_path)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return web.json_response({"status": "success", "count": deleted_count})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
GLOBAL_SCAN_STATE = {
|
||||
"scanning": False,
|
||||
"total": 0,
|
||||
"current": 0,
|
||||
"filename": "",
|
||||
"error": ""
|
||||
}
|
||||
GLOBAL_SCAN_LOCK = threading.Lock()
|
||||
|
||||
async def api_scan_missing_models_status(request):
|
||||
return web.json_response(GLOBAL_SCAN_STATE)
|
||||
|
||||
async def api_scan_missing_models(request):
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if plugin_dir not in sys.path:
|
||||
sys.path.insert(0, plugin_dir)
|
||||
|
||||
try:
|
||||
from scraper import calculate_sha256, fetch_civitai_info, infer_base_model_from_header, computed_file_identity
|
||||
except ImportError:
|
||||
return web.json_response({"status": "error", "message": "Failed to load scraper module"})
|
||||
|
||||
with GLOBAL_SCAN_LOCK:
|
||||
if GLOBAL_SCAN_STATE["scanning"]:
|
||||
return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409)
|
||||
GLOBAL_SCAN_STATE["scanning"] = True
|
||||
GLOBAL_SCAN_STATE["total"] = 0
|
||||
GLOBAL_SCAN_STATE["current"] = 0
|
||||
GLOBAL_SCAN_STATE["filename"] = "Initializing..."
|
||||
GLOBAL_SCAN_STATE["error"] = ""
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
force_overwrite = data.get("force_overwrite", False)
|
||||
|
||||
def run_deep_scan():
|
||||
try:
|
||||
paths_to_scan = get_active_scan_paths()
|
||||
files_to_scan = []
|
||||
for base_dir in paths_to_scan:
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if not file.endswith('.safetensors'):
|
||||
continue
|
||||
file_path = os.path.join(root, file)
|
||||
has_valid_info = not force_overwrite and bool(get_metadata(file_path).get("hash"))
|
||||
|
||||
if not has_valid_info:
|
||||
files_to_scan.append(file_path)
|
||||
|
||||
GLOBAL_SCAN_STATE["total"] = len(files_to_scan)
|
||||
|
||||
for idx, file_path in enumerate(files_to_scan):
|
||||
filename = os.path.basename(file_path)
|
||||
GLOBAL_SCAN_STATE["current"] = idx + 1
|
||||
GLOBAL_SCAN_STATE["filename"] = filename
|
||||
|
||||
try:
|
||||
file_hash = calculate_sha256(file_path)
|
||||
civitai_data = fetch_civitai_info(file_hash)
|
||||
|
||||
# Fallback 3: Local Offline Inference
|
||||
if not civitai_data:
|
||||
inferred_base = infer_base_model_from_header(file_path)
|
||||
if inferred_base == 'Unknown':
|
||||
inferred_base = ""
|
||||
|
||||
civitai_data = {
|
||||
"id": -1,
|
||||
"modelId": -1,
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"baseModel": inferred_base,
|
||||
"description": "<p>Automatically inferred by Anomalous Local Engine.</p>",
|
||||
"model": {
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"type": "LORA" if "lora" in file_path.lower() else "Checkpoint"
|
||||
},
|
||||
"files": [{"hashes": {"SHA256": file_hash}}]
|
||||
}
|
||||
|
||||
civitai_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
|
||||
|
||||
# Save info file
|
||||
info_path = os.path.splitext(file_path)[0] + ".info"
|
||||
with open(info_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(civitai_data, f, ensure_ascii=True, indent=4)
|
||||
|
||||
except Exception as e:
|
||||
GLOBAL_SCAN_STATE["error"] = f"{filename}: {e}"
|
||||
|
||||
except Exception as e:
|
||||
GLOBAL_SCAN_STATE["error"] = str(e)
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
GLOBAL_SCAN_STATE["scanning"] = False
|
||||
GLOBAL_SCAN_STATE["filename"] = ""
|
||||
|
||||
# Clear caches
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
folder_paths.filename_list_cache.clear()
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
folder_paths.cache_helper.clear()
|
||||
|
||||
try:
|
||||
threading.Thread(target=run_deep_scan, daemon=True).start()
|
||||
except Exception as e:
|
||||
GLOBAL_SCAN_STATE["scanning"] = False
|
||||
GLOBAL_SCAN_STATE["error"] = str(e)
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
return web.json_response({"status": "success", "message": "Deep scan started in background"})
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Bounded translation-provider HTTP route."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
def _translate_with_deepl(text, tl, deepl_key):
|
||||
deepl_map = { "zh-CN": "ZH", "en": "EN", "ja": "JA", "ko": "KO", "fr": "FR", "de": "DE", "es": "ES", "ru": "RU" }
|
||||
d_tl = deepl_map.get(tl, "EN")
|
||||
url = "https://api-free.deepl.com/v2/translate" if ":fx" in deepl_key else "https://api.deepl.com/v2/translate"
|
||||
payload = urllib.parse.urlencode({
|
||||
"auth_key": deepl_key,
|
||||
"text": text,
|
||||
"target_lang": d_tl
|
||||
}).encode('utf-8')
|
||||
req = urllib.request.Request(url, data=payload)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
return result["translations"][0]["text"]
|
||||
|
||||
|
||||
def _translate_with_google(text, tl):
|
||||
clients = ["dict-chrome-ex", "gtx"]
|
||||
last_err = None
|
||||
target_lang = "zh-CN" if tl in ("zh", "zh-CN") else tl
|
||||
for client in clients:
|
||||
try:
|
||||
url = f"https://translate.googleapis.com/translate_a/single?client={client}&sl=auto&tl={target_lang}&dt=t&q={urllib.parse.quote(text)}"
|
||||
req = urllib.request.Request(url, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
if result and isinstance(result, list) and len(result) > 0 and isinstance(result[0], list):
|
||||
translated_text = "".join([part[0] for part in result[0] if part and len(part) > 0 and part[0]])
|
||||
if translated_text:
|
||||
return translated_text
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
continue
|
||||
if last_err:
|
||||
raise last_err
|
||||
raise RuntimeError("Google translate returned empty result")
|
||||
|
||||
|
||||
def _translate_with_mymemory(text, tl):
|
||||
has_cn = any('\u4e00' <= char <= '\u9fa5' for char in text)
|
||||
sl = 'zh-CN' if has_cn else 'en'
|
||||
pair_tl = 'en' if has_cn else ('zh-CN' if tl in ('zh', 'zh-CN') else tl)
|
||||
url = f"https://api.mymemory.translated.net/get?q={urllib.parse.quote(text)}&langpair={sl}|{pair_tl}"
|
||||
req = urllib.request.Request(url, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode('utf-8'))
|
||||
res = data.get("responseData", {}).get("translatedText")
|
||||
if res and res != text:
|
||||
return res
|
||||
for m in data.get("matches", []):
|
||||
t_match = m.get("translation")
|
||||
if t_match and t_match != text:
|
||||
return t_match
|
||||
if res:
|
||||
return res
|
||||
raise RuntimeError("MyMemory returned no translation")
|
||||
|
||||
|
||||
async def api_translate(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
data = None
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
|
||||
text = str(data.get("text", "")).strip()
|
||||
try:
|
||||
tl = str(data.get("target_lang", "zh-CN")).strip()
|
||||
if not text:
|
||||
return web.json_response({"translated": "", "status": "success"})
|
||||
|
||||
# 1. Try DeepL if configured
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
deepl_key = ""
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
deepl_key = cfg.get("DEEPL_API_KEY", "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if deepl_key:
|
||||
try:
|
||||
translated = await asyncio.to_thread(_translate_with_deepl, text, tl, deepl_key)
|
||||
if translated:
|
||||
return web.json_response({"translated": translated, "status": "success", "engine": "deepl"})
|
||||
except Exception as e:
|
||||
print(f"[Anomalous] DeepL translate failed: {e}")
|
||||
|
||||
# 2. Try Google Translate (client=dict-chrome-ex)
|
||||
try:
|
||||
translated = await asyncio.to_thread(_translate_with_google, text, tl)
|
||||
if translated:
|
||||
return web.json_response({"translated": translated, "status": "success", "engine": "google"})
|
||||
except Exception as e:
|
||||
print(f"[Anomalous] Google translate failed: {e}")
|
||||
|
||||
# 3. Fallback to MyMemory
|
||||
try:
|
||||
translated = await asyncio.to_thread(_translate_with_mymemory, text, tl)
|
||||
if translated:
|
||||
return web.json_response({"translated": translated, "status": "success", "engine": "mymemory"})
|
||||
except Exception as e:
|
||||
print(f"[Anomalous] MyMemory translate failed: {e}")
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Anomalous] All translation providers failed: {e}")
|
||||
return web.json_response({"translated": text, "error": str(e), "status": "error"})
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Compatibility exports for focused backend utility modules."""
|
||||
|
||||
import os
|
||||
|
||||
from . import gallery_routes as _gallery
|
||||
from .folder_types import *
|
||||
from .media_routes import *
|
||||
from .path_utils import *
|
||||
from .translation_routes import *
|
||||
|
||||
|
||||
_gallery_snapshot = _gallery._gallery_snapshot
|
||||
_gallery_collect_impl = _gallery._collect_gallery_images
|
||||
_gallery_images_impl = _gallery._gallery_images
|
||||
|
||||
|
||||
def _collect_gallery_images(output_dir):
|
||||
return _gallery_collect_impl(output_dir)
|
||||
|
||||
|
||||
def _gallery_images(output_dir, refresh=False):
|
||||
_gallery._collect_gallery_images = _collect_gallery_images
|
||||
return _gallery_images_impl(output_dir, refresh)
|
||||
|
||||
|
||||
def _invalidate_gallery_snapshot():
|
||||
return _gallery._invalidate_gallery_snapshot()
|
||||
|
||||
|
||||
api_get_gallery_images = _gallery.api_get_gallery_images
|
||||
api_delete_gallery_image = _gallery.api_delete_gallery_image
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Plugin version display, published-release listing and version switching.
|
||||
|
||||
The plugin is normally installed as a git clone (ComfyUI Manager), so the
|
||||
installed version is read from git and switching checks out a published
|
||||
release tag. Nothing runs automatically: every network call starts from a
|
||||
button in the UI. Switching refuses when tracked files have local changes, and
|
||||
only tags that are published releases (or at least exist on the remote) can be
|
||||
checked out.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
STATE_FILE = os.path.join(PLUGIN_DIR, ".anomalous_version.local.json")
|
||||
# A version whose tree contains this file has the version panel; older ones do not.
|
||||
SWITCHER_MARKER = "api/version_manager.py"
|
||||
TAG_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,99}$")
|
||||
GITHUB_REMOTE = re.compile(r"github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$", re.IGNORECASE)
|
||||
RELEASES_CACHE_SECONDS = 300
|
||||
NETWORK_TIMEOUT = 10
|
||||
FETCH_TIMEOUT = 180
|
||||
|
||||
_lock = threading.Lock()
|
||||
_releases_cache = {"expires": 0, "data": None}
|
||||
|
||||
|
||||
class VersionError(Exception):
|
||||
"""A user-facing failure with a stable code for the UI."""
|
||||
|
||||
def __init__(self, code, message="", **details):
|
||||
super().__init__(message or code)
|
||||
self.code = code
|
||||
self.details = details
|
||||
|
||||
|
||||
# ---------- git helpers ----------
|
||||
|
||||
def _git_executable():
|
||||
found = shutil.which("git")
|
||||
if not found:
|
||||
raise VersionError("git_missing", "git executable not found")
|
||||
return found
|
||||
|
||||
|
||||
def _git(*args, timeout=30, check=True):
|
||||
kwargs = {}
|
||||
if os.name == "nt":
|
||||
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[_git_executable(), "-C", PLUGIN_DIR, *args],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
timeout=timeout, env=env, **kwargs,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise VersionError("git_timeout", f"git {' '.join(args[:2])} timed out")
|
||||
if check and result.returncode != 0:
|
||||
raise VersionError("git_failed", (result.stderr or result.stdout).strip()[-500:])
|
||||
return result
|
||||
|
||||
|
||||
def _git_out(*args, **kwargs):
|
||||
result = _git(*args, check=False, **kwargs)
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def version_key(tag):
|
||||
"""Sortable key for tags such as v1.4.2, v1.57-beta, v1.58.0-rc1 (stable after pre-release)."""
|
||||
match = re.match(r"^v?(\d+(?:\.\d+)*)(.*)$", str(tag or ""))
|
||||
if not match:
|
||||
return ((), 0, str(tag))
|
||||
numbers = tuple(int(part) for part in match.group(1).split("."))
|
||||
suffix = match.group(2).lstrip("-_.")
|
||||
return (numbers, 1 if not suffix else 0, suffix)
|
||||
|
||||
|
||||
def current_state():
|
||||
"""Local only (no network): what is installed and whether it can be switched."""
|
||||
if not os.path.isdir(os.path.join(PLUGIN_DIR, ".git")) and _git_out("rev-parse", "--is-inside-work-tree") != "true":
|
||||
return {"is_git": False}
|
||||
commit = _git_out("rev-parse", "HEAD")
|
||||
branch = _git_out("symbolic-ref", "--short", "-q", "HEAD") or None
|
||||
exact_tag = _git_out("describe", "--tags", "--exact-match", "HEAD") or None
|
||||
described = _git_out("describe", "--tags", "--always")
|
||||
base_tag = _git_out("describe", "--tags", "--abbrev=0") or None
|
||||
# Porcelain lines are "XY path"; keep the leading status column (no strip before slicing).
|
||||
status = _git("status", "--porcelain", "--untracked-files=no", check=False).stdout
|
||||
changed = [line[3:] for line in status.splitlines() if line.strip()]
|
||||
return {
|
||||
"is_git": True,
|
||||
"commit": commit[:12],
|
||||
"branch": branch,
|
||||
"tag": exact_tag,
|
||||
"base_tag": base_tag,
|
||||
"label": exact_tag or described or commit[:12],
|
||||
"dirty": bool(changed),
|
||||
"changed_files": changed[:20],
|
||||
"remote": _git_out("remote", "get-url", "origin") or None,
|
||||
"previous": _read_state().get("previous"),
|
||||
}
|
||||
|
||||
|
||||
# ---------- releases ----------
|
||||
|
||||
def _github_repo(remote_url):
|
||||
match = GITHUB_REMOTE.search(remote_url or "")
|
||||
return (match.group(1), match.group(2)) if match else None
|
||||
|
||||
|
||||
def _fetch_github_releases(owner, repo):
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=50"
|
||||
request = urllib.request.Request(url, headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "Anomalous-Model-Browser",
|
||||
})
|
||||
with urllib.request.urlopen(request, timeout=NETWORK_TIMEOUT) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
releases = []
|
||||
for item in payload if isinstance(payload, list) else []:
|
||||
if item.get("draft") or not item.get("tag_name"):
|
||||
continue
|
||||
releases.append({
|
||||
"tag": item["tag_name"],
|
||||
"name": item.get("name") or item["tag_name"],
|
||||
"published_at": item.get("published_at"),
|
||||
"prerelease": bool(item.get("prerelease")),
|
||||
"notes": (item.get("body") or "")[:8000],
|
||||
"url": item.get("html_url"),
|
||||
})
|
||||
return releases
|
||||
|
||||
|
||||
def _remote_tags():
|
||||
output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3)
|
||||
tags = [line.split("refs/tags/", 1)[1] for line in output.splitlines() if "refs/tags/" in line]
|
||||
return [{"tag": tag, "name": tag, "published_at": None, "prerelease": False, "notes": "", "url": None} for tag in tags]
|
||||
|
||||
|
||||
def list_releases(force=False):
|
||||
"""Published releases, newest first. GitHub API first, `git ls-remote` (uses git's proxy) as fallback."""
|
||||
state = current_state()
|
||||
if not state.get("is_git"):
|
||||
raise VersionError("not_git", "The plugin is not a git checkout")
|
||||
now = time.monotonic()
|
||||
if not force and _releases_cache["data"] and now < _releases_cache["expires"]:
|
||||
releases, source = _releases_cache["data"]
|
||||
else:
|
||||
releases, source = None, None
|
||||
repo = _github_repo(state.get("remote"))
|
||||
if repo:
|
||||
try:
|
||||
releases, source = _fetch_github_releases(*repo), "github"
|
||||
except Exception:
|
||||
releases = None
|
||||
if releases is None:
|
||||
releases, source = _remote_tags(), "git"
|
||||
if not releases:
|
||||
raise VersionError("offline", "Could not reach GitHub or the git remote")
|
||||
releases.sort(key=lambda item: (item["published_at"] or "", version_key(item["tag"])), reverse=True)
|
||||
if source == "git":
|
||||
releases.sort(key=lambda item: version_key(item["tag"]), reverse=True)
|
||||
_releases_cache.update(expires=now + RELEASES_CACHE_SECONDS, data=(releases, source))
|
||||
|
||||
current_key = version_key(state.get("tag") or state.get("base_tag"))
|
||||
stable = [item for item in releases if not item["prerelease"]]
|
||||
latest = stable[0]["tag"] if stable else None
|
||||
result = []
|
||||
for item in releases:
|
||||
key = version_key(item["tag"])
|
||||
entry = dict(item)
|
||||
entry["current"] = item["tag"] == state.get("tag")
|
||||
entry["latest"] = item["tag"] == latest
|
||||
entry["direction"] = "same" if entry["current"] else ("newer" if key > current_key else "older")
|
||||
result.append(entry)
|
||||
return {"state": state, "releases": result, "latest": latest, "source": source}
|
||||
|
||||
|
||||
# ---------- switching ----------
|
||||
|
||||
def _read_state():
|
||||
try:
|
||||
with open(STATE_FILE, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _write_state(data):
|
||||
temporary = STATE_FILE + ".tmp"
|
||||
with open(temporary, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, ensure_ascii=False, indent=2)
|
||||
os.replace(temporary, STATE_FILE)
|
||||
|
||||
|
||||
def _require_switchable():
|
||||
state = current_state()
|
||||
if not state.get("is_git"):
|
||||
raise VersionError("not_git", "The plugin is not a git checkout")
|
||||
if state["dirty"]:
|
||||
raise VersionError("dirty", "Tracked files have local changes", files=state["changed_files"])
|
||||
return state
|
||||
|
||||
|
||||
def _known_tag(tag):
|
||||
if not TAG_NAME.match(str(tag or "")):
|
||||
raise VersionError("bad_tag", "Invalid version name")
|
||||
tags = {item["tag"] for item in list_releases()["releases"]}
|
||||
if tag not in tags:
|
||||
raise VersionError("unknown_tag", f"{tag} is not a published version")
|
||||
return tag
|
||||
|
||||
|
||||
def _fetch_tag(tag):
|
||||
_git("fetch", "--no-tags", "--force", "origin", f"+refs/tags/{tag}:refs/tags/{tag}", timeout=FETCH_TIMEOUT)
|
||||
|
||||
|
||||
def preview_switch(tag):
|
||||
"""Fetch the target and report what switching would mean, without changing anything."""
|
||||
state = _require_switchable()
|
||||
_known_tag(tag)
|
||||
_fetch_tag(tag)
|
||||
has_switcher = _git("cat-file", "-e", f"refs/tags/{tag}:{SWITCHER_MARKER}", check=False).returncode == 0
|
||||
target_key = version_key(tag)
|
||||
current_key = version_key(state.get("tag") or state.get("base_tag"))
|
||||
return {
|
||||
"tag": tag,
|
||||
"direction": "same" if tag == state.get("tag") else ("newer" if target_key > current_key else "older"),
|
||||
"has_switcher": has_switcher,
|
||||
"from": state["label"],
|
||||
"from_branch": state["branch"],
|
||||
}
|
||||
|
||||
|
||||
def switch_to(tag):
|
||||
with _lock:
|
||||
state = _require_switchable()
|
||||
_known_tag(tag)
|
||||
_fetch_tag(tag)
|
||||
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
|
||||
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", f"refs/tags/{tag}")
|
||||
_write_state({"previous": previous, "switched_at": time.time()})
|
||||
return {"state": current_state(), "restart_required": True}
|
||||
|
||||
|
||||
def _default_branch():
|
||||
ref = _git_out("symbolic-ref", "--short", "refs/remotes/origin/HEAD")
|
||||
if ref.startswith("origin/"):
|
||||
return ref.split("/", 1)[1]
|
||||
for name in ("main", "master"):
|
||||
if _git("ls-remote", "--exit-code", "--heads", "origin", name, check=False, timeout=NETWORK_TIMEOUT * 3).returncode == 0:
|
||||
return name
|
||||
raise VersionError("no_default_branch", "Could not determine the default branch")
|
||||
|
||||
|
||||
def return_to_latest():
|
||||
"""Back to the default branch at its newest commit; the same path ComfyUI Manager's update takes."""
|
||||
with _lock:
|
||||
state = _require_switchable()
|
||||
branch = _default_branch()
|
||||
_git("fetch", "--no-tags", "origin", f"+refs/heads/{branch}:refs/remotes/origin/{branch}", timeout=FETCH_TIMEOUT)
|
||||
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
|
||||
if _git("rev-parse", "--verify", "--quiet", f"refs/heads/{branch}", check=False).returncode == 0:
|
||||
_git("checkout", "--quiet", branch)
|
||||
else:
|
||||
_git("checkout", "--quiet", "-b", branch, f"origin/{branch}")
|
||||
merge = _git("merge", "--ff-only", "--quiet", f"origin/{branch}", check=False)
|
||||
if merge.returncode != 0:
|
||||
raise VersionError("diverged", f"Local {branch} has commits that are not on the remote")
|
||||
_write_state({"previous": previous, "switched_at": time.time()})
|
||||
return {"state": current_state(), "restart_required": True}
|
||||
|
||||
|
||||
def undo_last_switch():
|
||||
with _lock:
|
||||
previous = _read_state().get("previous")
|
||||
if not previous or not previous.get("commit"):
|
||||
raise VersionError("no_previous", "There is no switch to undo")
|
||||
state = _require_switchable()
|
||||
here = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
|
||||
if previous.get("branch"):
|
||||
# Going back to a branch means its current tip, even if it moved since (e.g. a Manager update).
|
||||
_git("checkout", "--quiet", previous["branch"])
|
||||
else:
|
||||
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", previous["commit"])
|
||||
_write_state({"previous": here, "switched_at": time.time()})
|
||||
return {"state": current_state(), "restart_required": True}
|
||||
|
||||
|
||||
# ---------- routes ----------
|
||||
|
||||
def _error_response(error):
|
||||
status = 409 if error.code in {"dirty", "diverged"} else 400
|
||||
if error.code in {"offline", "git_timeout", "git_failed"}:
|
||||
status = 502
|
||||
return web.json_response({"success": False, "code": error.code, "error": str(error), **error.details}, status=status)
|
||||
|
||||
|
||||
async def _run(handler, *args):
|
||||
try:
|
||||
return web.json_response({"success": True, **(await asyncio.to_thread(handler, *args))})
|
||||
except VersionError as error:
|
||||
return _error_response(error)
|
||||
|
||||
|
||||
async def _json_body(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
async def api_get_version(request):
|
||||
"""GET /anomalous/version - Installed version (local, no network)."""
|
||||
try:
|
||||
return web.json_response({"success": True, "state": await asyncio.to_thread(current_state)})
|
||||
except VersionError as error:
|
||||
return _error_response(error)
|
||||
|
||||
|
||||
async def api_get_releases(request):
|
||||
"""GET /anomalous/version/releases?refresh=1 - Published releases (network, only when asked)."""
|
||||
return await _run(list_releases, request.query.get("refresh") == "1")
|
||||
|
||||
|
||||
async def api_preview_switch(request):
|
||||
"""POST /anomalous/version/preview {tag}"""
|
||||
return await _run(preview_switch, str((await _json_body(request)).get("tag", "")))
|
||||
|
||||
|
||||
async def api_switch_version(request):
|
||||
"""POST /anomalous/version/switch {tag}"""
|
||||
return await _run(switch_to, str((await _json_body(request)).get("tag", "")))
|
||||
|
||||
|
||||
async def api_return_to_latest(request):
|
||||
"""POST /anomalous/version/latest"""
|
||||
return await _run(return_to_latest)
|
||||
|
||||
|
||||
async def api_undo_switch(request):
|
||||
"""POST /anomalous/version/undo"""
|
||||
return await _run(undo_last_switch)
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_get('/anomalous/version', api_get_version)
|
||||
app.router.add_get('/anomalous/version/releases', api_get_releases)
|
||||
app.router.add_post('/anomalous/version/preview', api_preview_switch)
|
||||
app.router.add_post('/anomalous/version/switch', api_switch_version)
|
||||
app.router.add_post('/anomalous/version/latest', api_return_to_latest)
|
||||
app.router.add_post('/anomalous/version/undo', api_undo_switch)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Pure Workflow Recipe graph validation and fingerprinting."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
|
||||
from .recipe_constants import *
|
||||
|
||||
def _normalise_workflow_key(value):
|
||||
return re.sub(r"[^a-z0-9]+", "_", str(value).strip().lower()).strip("_")
|
||||
|
||||
|
||||
def _volatile_widget_indexes(node):
|
||||
"""Known serialized LiteGraph widget positions for native sampler seeds."""
|
||||
node_type = _node_type(node).lower()
|
||||
if node_type == "ksampler":
|
||||
return (0,)
|
||||
if node_type == "ksampleradvanced":
|
||||
return (1,)
|
||||
return ()
|
||||
|
||||
|
||||
def _clean_volatile_params(value):
|
||||
"""Produce a stable graph view while retaining all generation-defining data."""
|
||||
if isinstance(value, list):
|
||||
return [_clean_volatile_params(item) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
|
||||
cleaned = {}
|
||||
for key, nested_value in value.items():
|
||||
if _normalise_workflow_key(key) in VOLATILE_WORKFLOW_KEYS:
|
||||
continue
|
||||
cleaned[str(key)] = _clean_volatile_params(nested_value)
|
||||
|
||||
widgets = cleaned.get("widgets_values")
|
||||
if isinstance(widgets, list):
|
||||
for index in _volatile_widget_indexes(value):
|
||||
if 0 <= index < len(widgets):
|
||||
widgets[index] = "__anomalous_volatile_seed__"
|
||||
return cleaned
|
||||
|
||||
|
||||
def _workflow_fingerprint(workflow):
|
||||
"""Hash the workflow structure after removing known run-volatile values."""
|
||||
canonical = json.dumps(
|
||||
_clean_volatile_params(workflow),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return {
|
||||
"algorithm": "sha256-structural-v1",
|
||||
"value": hashlib.sha256(canonical).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def _parameter_signature(workflow):
|
||||
"""Hash node types and parameter values for UI workflows or API prompts."""
|
||||
if not isinstance(workflow, dict):
|
||||
return {"algorithm": "sha256-params-v1", "value": ""}
|
||||
|
||||
nodes = workflow.get("nodes")
|
||||
is_ui_workflow = isinstance(nodes, list)
|
||||
if not is_ui_workflow:
|
||||
nodes = list(workflow.values())
|
||||
|
||||
cleaned_nodes = []
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
if is_ui_workflow:
|
||||
parameters = {"widgets": _clean_volatile_params(node.get("widgets_values") or [])}
|
||||
widgets = parameters["widgets"]
|
||||
if isinstance(widgets, list):
|
||||
for index in _volatile_widget_indexes(node):
|
||||
if 0 <= index < len(widgets):
|
||||
widgets[index] = "__anomalous_volatile_seed__"
|
||||
else:
|
||||
parameters = {"inputs": _clean_volatile_params(node.get("inputs") or {})}
|
||||
|
||||
cleaned_nodes.append({
|
||||
"type": _node_type(node),
|
||||
**parameters,
|
||||
})
|
||||
|
||||
cleaned_nodes.sort(key=lambda x: (x["type"], json.dumps(x, sort_keys=True, ensure_ascii=False)))
|
||||
|
||||
canonical = json.dumps(
|
||||
cleaned_nodes,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
return {
|
||||
"algorithm": "sha256-params-v1",
|
||||
"value": hashlib.sha256(canonical).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def _workflow_node_types(workflow):
|
||||
"""Extract only node class names from either UI workflow or API prompt data."""
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
nodes = workflow.get("nodes")
|
||||
if isinstance(nodes, list):
|
||||
values = nodes
|
||||
else:
|
||||
values = workflow.values()
|
||||
result = []
|
||||
for node in values:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_type = _node_type(node) or str(node.get("class_type") or "").strip()
|
||||
if node_type:
|
||||
result.append(node_type)
|
||||
return sorted(result, key=lambda value: value.casefold())
|
||||
|
||||
|
||||
def _workflow_node_signature(workflow):
|
||||
"""Stable node-composition signature used for tolerant result discovery."""
|
||||
canonical = json.dumps(
|
||||
_workflow_node_types(workflow),
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return {
|
||||
"algorithm": "sha256-node-types-v1",
|
||||
"value": hashlib.sha256(canonical).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def _node_type(node):
|
||||
return str(node.get("type") or node.get("class_type") or "").strip()
|
||||
|
||||
|
||||
def _node_title(node):
|
||||
meta = node.get("_meta") if isinstance(node.get("_meta"), dict) else {}
|
||||
return str(meta.get("title") or node.get("title") or _node_type(node) or "Unknown node").strip()
|
||||
|
||||
|
||||
def _widget_values(node):
|
||||
values = node.get("widgets_values")
|
||||
return values if isinstance(values, list) else []
|
||||
|
||||
|
||||
def _workflow_node_key(value):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
||||
raise ValueError("Invalid workflow node id")
|
||||
value = str(value).strip()
|
||||
if not value:
|
||||
raise ValueError("Invalid workflow node id")
|
||||
return value
|
||||
|
||||
|
||||
def _workflow_link_records(workflow):
|
||||
links = workflow.get("links", [])
|
||||
if links is None:
|
||||
return []
|
||||
if isinstance(links, list):
|
||||
return links
|
||||
if isinstance(links, dict):
|
||||
return list(links.values())
|
||||
raise ValueError("Invalid workflow links")
|
||||
|
||||
|
||||
def _validate_workflow(workflow):
|
||||
"""Reject malformed graph topology before it reaches user recipe storage."""
|
||||
if not isinstance(workflow, dict):
|
||||
raise ValueError("Invalid recipe workflow")
|
||||
nodes = workflow.get("nodes")
|
||||
if not isinstance(nodes, list) or len(nodes) > MAX_WORKFLOW_NODES:
|
||||
raise ValueError("Invalid workflow nodes")
|
||||
|
||||
node_ids = set()
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
raise ValueError("Invalid workflow node")
|
||||
node_key = _workflow_node_key(node.get("id"))
|
||||
if node_key in node_ids:
|
||||
raise ValueError("Duplicate workflow node id")
|
||||
node_ids.add(node_key)
|
||||
values = node.get("widgets_values")
|
||||
if values is not None and (not isinstance(values, list) or len(values) > MAX_WIDGET_VALUES_PER_NODE):
|
||||
raise ValueError("Invalid workflow widget values")
|
||||
|
||||
groups = workflow.get("groups", [])
|
||||
if groups is not None and (not isinstance(groups, list) or len(groups) > MAX_WORKFLOW_GROUPS):
|
||||
raise ValueError("Invalid workflow groups")
|
||||
|
||||
links = _workflow_link_records(workflow)
|
||||
if len(links) > MAX_WORKFLOW_LINKS:
|
||||
raise ValueError("Too many workflow links")
|
||||
for link in links:
|
||||
if isinstance(link, list) and len(link) >= 5:
|
||||
origin_id, target_id = link[1], link[3]
|
||||
elif isinstance(link, dict):
|
||||
origin_id, target_id = link.get("origin_id"), link.get("target_id")
|
||||
else:
|
||||
raise ValueError("Invalid workflow link")
|
||||
if _workflow_node_key(origin_id) not in node_ids or _workflow_node_key(target_id) not in node_ids:
|
||||
raise ValueError("Dangling workflow link")
|
||||
|
||||
|
||||
def _recipe_receipt(recipe, filename):
|
||||
"""Return a small, user-visible confirmation for the accepted graph."""
|
||||
workflow = recipe.get("workflow") if isinstance(recipe, dict) else {}
|
||||
params = recipe.get("params") if isinstance(recipe, dict) else {}
|
||||
return {
|
||||
"filename": filename,
|
||||
"node_count": len(workflow.get("nodes", [])) if isinstance(workflow, dict) else 0,
|
||||
"link_count": len(_workflow_link_records(workflow)) if isinstance(workflow, dict) else 0,
|
||||
"group_count": len(workflow.get("groups", [])) if isinstance(workflow.get("groups", []), list) else 0,
|
||||
"parameter_node_count": len(params.get("nodes", [])) if isinstance(params, dict) and isinstance(params.get("nodes", []), list) else 0,
|
||||
"pinned_count": len(params.get("pinned", [])) if isinstance(params, dict) and isinstance(params.get("pinned", []), list) else 0,
|
||||
"workflow_fingerprint": recipe.get("workflow_fingerprint") if isinstance(recipe, dict) else None,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Architecture Topic Guide
|
||||
|
||||
Start with the repository-root [`ARCHITECTURE.md`](../../ARCHITECTURE.md), then
|
||||
open only the topic needed for the current task.
|
||||
|
||||
- [`backend.md`](backend.md): Python routes, filesystem safety, persistence,
|
||||
scan state, model sidecars, and backend performance.
|
||||
- [`frontend.md`](frontend.md): extension bootstrap, UI ownership, localization,
|
||||
media lifecycle, panel state, and graph mutations.
|
||||
- [`recipes.md`](recipes.md): Workflow Recipes, packages, galleries, Parameter
|
||||
Notebooks, prompt roles, and recipe-to-model transitions.
|
||||
- [`material-library.md`](material-library.md): generated-image snapshots,
|
||||
material assets, node-block reuse, and Model Doctor handoff.
|
||||
- [`recipe-model-lifecycle.md`](recipe-model-lifecycle.md): authoritative model
|
||||
fields, Hash synchronization, local matching, partial append, and note privacy.
|
||||
- [`model-resolution.md`](model-resolution.md): model provenance, Model Doctor,
|
||||
hash injection, deep scanning, and recovery policy.
|
||||
- [`../decisions/README.md`](../decisions/README.md): concise records of product
|
||||
and architectural decisions that still explain current behavior.
|
||||
|
||||
These documents describe current contracts rather than implementation history.
|
||||
For exact changes, use Git history. For user-visible releases, use
|
||||
`CHANGELOG.md`. For recurring implementation traps, use
|
||||
`.agents/logs/ai_lessons.md`.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Backend Architecture
|
||||
|
||||
Read this document for changes to `api/`, `scraper.py`, model files and sidecars,
|
||||
recipe persistence, or filesystem-facing routes.
|
||||
|
||||
## Runtime and route boundary
|
||||
|
||||
`__init__.py` exposes the frontend directory and imports the modular route
|
||||
package. `api/__init__.py` registers `aiohttp` routes, all prefixed with
|
||||
`/anomalous/`. Changes to Python modules require a full ComfyUI restart; a
|
||||
frontend reload alone does not replace registered handlers or module state.
|
||||
Route registration uses explicit module references. `tests/test_route_manifest.py`
|
||||
locks the HTTP method/path pairs so internal ownership changes cannot silently
|
||||
drop, duplicate, or rename an endpoint.
|
||||
|
||||
The backend owns filesystem authority. A browser-supplied path, filename,
|
||||
category, output reference, recipe asset, or archive member is untrusted until
|
||||
validated. Use the shared helpers in `api/path_utils.py` (also re-exported by the
|
||||
small `api/utils.py` compatibility facade):
|
||||
|
||||
- `resolve_folder_subdir()` for configured model-folder boundaries;
|
||||
- `resolve_within()` for containment below an owned root;
|
||||
- `require_filename()` for single-file identifiers.
|
||||
|
||||
Never rely only on `..` rejection. Absolute Windows paths, UNC paths, alternate
|
||||
separators, device paths, and symlinks can bypass naïve string checks. Media
|
||||
routes also enforce an explicit extension allowlist.
|
||||
|
||||
Configured model scope belongs to `api.folder_types`. Category mode uses
|
||||
`get_active_folder_types()`; folder-manager-facing library enumeration uses
|
||||
`get_active_model_roots()` so physical mode can retain the exact registered
|
||||
`type`, `path_idx`, and real root for every enabled directory. Real roots are
|
||||
deduplicated without merging same-named files from different roots. Do not
|
||||
hardcode a walk across `checkpoints`, `loras`, or another category. A directory
|
||||
disabled in `config.json` must cause no walk or metadata I/O.
|
||||
|
||||
`/anomalous/all_scan_models` follows that current folder-manager mode and keeps
|
||||
its paginated response plus `limit=0` full-list behavior. Its local source-link
|
||||
inventory recognizes GGUF and PTH case-insensitively in addition to the existing
|
||||
list formats. This route-level inventory does not expand global model-format,
|
||||
scanner, metadata-parser, recovery, or cloud-identification support.
|
||||
|
||||
## Storage ownership
|
||||
|
||||
Runtime UI settings and newly stored API keys live in `api/config.json`.
|
||||
`scraper.py` reads it first and may fall back to the legacy root `config.json`.
|
||||
Secrets are never persisted to browser `localStorage`.
|
||||
|
||||
Workflow Recipes live below the active ComfyUI user directory in
|
||||
`workflows/anomalous_recipes`; Parameter Notebooks live in
|
||||
`workflows/anomalous_parameters`. They are user data, not repository assets.
|
||||
Writes validate their bounded schema and use atomic replacement.
|
||||
|
||||
Backend modules follow route/domain/storage ownership. Model discovery,
|
||||
identity recovery, metadata mutation, and cover media live in
|
||||
`model_catalog.py`, `model_resolution.py`, `model_metadata.py`, and
|
||||
`model_media.py`. Recipe graph rules, recipe shaping, image processing, and
|
||||
persistence/history live in `workflow_schema.py`, `recipe_schema.py`,
|
||||
`recipe_images.py`, and `recipe_store.py`; `recipes.py` is the HTTP facade.
|
||||
Material shaping, private assets, and the single persistence lock/summary cache
|
||||
live in `material_schema.py`, `material_assets.py`, and `material_store.py`;
|
||||
`materials.py` owns request/response mapping and compatibility entry points.
|
||||
|
||||
The former mixed utility routes are separated: `media_routes.py` owns card
|
||||
thumbnails and model/output media lookups, `gallery_routes.py` owns the bounded
|
||||
output snapshot and deletion (searched through `image_search.py`, which reads
|
||||
PNG text chunks only and caches per-image records by mtime), `translation_routes.py`
|
||||
owns provider fallback, and `folder_types.py` owns configured visibility and scan scope.
|
||||
|
||||
`version_manager.py` runs git against the plugin checkout only (no shell, no
|
||||
prompts, `CREATE_NO_WINDOW` on Windows) and accepts only tags that appear in the
|
||||
published release list. It refuses switches while tracked files are modified,
|
||||
never rewrites a local branch that has diverged from the remote, and reports
|
||||
stable error codes (`dirty`, `diverged`, `offline`, `unknown_tag`, ...) that the UI
|
||||
localizes. Switching changes code only; a ComfyUI restart applies it.
|
||||
|
||||
Offline inference sidecars use non-positive Civitai IDs as sentinels. Metadata
|
||||
normalization must not expose those values as release-page URLs or resolved
|
||||
model/version identities; only positive IDs may form a Civitai source link.
|
||||
|
||||
Prompt Notes use `workflows/anomalous_notebooks`. First access copies legacy
|
||||
`api/notebooks` records without deleting originals or overwriting current notes.
|
||||
Conflicts receive a deterministic recovered filename. A completion marker makes
|
||||
the copy retryable after write failure and prevents deleted notes reappearing.
|
||||
Invalid legacy records remain untouched and are listed in the migration marker.
|
||||
Notebook I/O runs in a worker under its persistence lock; the UI queues snapshots
|
||||
and reports failed saves instead of showing success. `api.path_utils.atomic_write_json`
|
||||
owns bounded, flushed temporary writes followed by atomic file replacement.
|
||||
|
||||
The recipe card endpoint returns lightweight metadata. Full graphs and history
|
||||
are fetched only for detail, edit, compare, restore, export, or another operation
|
||||
that needs them. Successful save, update, and restore responses include a compact
|
||||
integrity receipt containing graph counts and the persisted workflow fingerprint.
|
||||
|
||||
Package import is inspect-then-commit. `recipe_packages.py` accepts only bounded
|
||||
ZIP packages with a manifest, recipe JSON, declared contained WebP assets, and
|
||||
optional bounded history. It rejects traversal, symlinks, undeclared members,
|
||||
archive bombs, and checksum failures; stages all content before the final rename;
|
||||
and restores the previous recipe set if replacement commit fails. Packages never
|
||||
install code or dependencies.
|
||||
|
||||
## Event-loop and scan-state rules
|
||||
|
||||
Potentially large disk work does not run on the `aiohttp` event loop. Recursive
|
||||
walks, bulk metadata parsing, full-file hashing, and similar operations use
|
||||
`asyncio.to_thread()` or the established worker process/thread path. Reading a
|
||||
small bounded safetensors header is acceptable; reading a whole multi-gigabyte
|
||||
model to discover metadata is not.
|
||||
|
||||
Background work claims state before launching. Folder scans use
|
||||
`.scan_in_progress`; global quick scans use `.global_scan_in_progress`; deep
|
||||
missing-model scans use `GLOBAL_SCAN_STATE` and the corresponding status route.
|
||||
Marker files are versioned JSON records containing backend session, owner PID,
|
||||
worker PID, and job ID. Status checks validate ownership and process liveness;
|
||||
file existence alone is not proof that a scan is active.
|
||||
|
||||
A marker owned by a dead process, or a legacy plain-text marker, represents an
|
||||
interrupted job. Its marker, progress file, and pending selection may be removed
|
||||
so a restart does not permanently lock scanning. A still-running orphan worker
|
||||
remains locked until that recorded process exits.
|
||||
|
||||
`scraper.py --progress-file` atomically publishes enumeration state, selected
|
||||
file count, current index, and filename. Status responses merge worker progress
|
||||
with parent-owned folder progress. The frontend can reconstruct this state by
|
||||
polling after its UI has been reopened.
|
||||
|
||||
## Metadata and cache behavior
|
||||
|
||||
The output gallery keeps one ordered directory snapshot for at most ten seconds
|
||||
and 50,000 images. Page requests reuse it; explicit refresh and image deletion
|
||||
invalidate it immediately. Larger inventories are returned normally but not cached.
|
||||
|
||||
Metadata and embedded safetensors-header hashes may be cached only in a bounded
|
||||
cache. The key includes the model's real path and the physical `size`, `mtime_ns`,
|
||||
and `ctime_ns` signatures of the model and relevant sidecars. Return independent
|
||||
copies; never expose cached mutable dictionaries or lists.
|
||||
|
||||
Folder listing inventories a directory once with `os.scandir()` and preserves
|
||||
preview priority: `.preview.*` before bare same-stem media. Avoid an `exists()`
|
||||
sequence for every candidate when the directory inventory already has the
|
||||
answer.
|
||||
|
||||
Preview URLs use the selected preview file's stable nanosecond modification time
|
||||
as the cache version. Do not append `Date.now()` or random request tokens during
|
||||
ordinary listing. A changed file must change its URL; an unchanged file should
|
||||
remain browser-cacheable.
|
||||
|
||||
Preview lookup tries a contained exact relative path first and recursively walks
|
||||
the library only for unresolved basename fallbacks. This locates presentation
|
||||
for a model value already supplied by ComfyUI; it is not Model Doctor discovery.
|
||||
|
||||
Balanced grid thumbnails are derived, longest-edge 512 px WebP files in
|
||||
ComfyUI's temporary area. Their cache is keyed by source real path and physical
|
||||
signature and capped at 256 MiB with oldest-accessed eviction. The original mode
|
||||
serves source covers, and detail views always use originals. Derived media never
|
||||
modifies or sits beside a user's cover; unsupported, animated, or failed inputs
|
||||
fall back to the original.
|
||||
|
||||
## Model sidecar and cover lifecycle
|
||||
|
||||
Sidecar handling is non-negotiable because it can destroy user metadata or
|
||||
media.
|
||||
|
||||
- `.civitai_bak.*` is a persistent restore source created from a real Civitai
|
||||
download. Setting a custom cover changes only `.preview.*`.
|
||||
- Cover reset first restores `.civitai_bak.*` to `.preview.*`. Otherwise it may
|
||||
remove `.preview.*` only when a bare original cover can take over. If the
|
||||
preview is the only image, preserve it and return a visible warning. Reset
|
||||
never silently downloads from the network.
|
||||
- Physical rename moves every recognized sidecar to the new model stem. Model
|
||||
deletion may clean sidecars only after the main model is successfully deleted.
|
||||
- Foundation components—`vae`, `vae_approx`, `clip`, `text_encoders`, and
|
||||
`clip_vision`—are never physical-rename targets. UI and all backend entry
|
||||
points enforce the same denial.
|
||||
- Main model extensions (`.safetensors`, `.ckpt`, `.pt`, `.bin`) are never
|
||||
sidecar suffixes. Cleanup must not delete a same-stem model with another
|
||||
extension. If such a sibling remains, ambiguous stem-keyed sidecars remain.
|
||||
- Rename/delete/reset uses centralized immutable suffix tuples and a constant
|
||||
number of exact-path checks. Do not walk, glob, index, hash, or call the
|
||||
network for these operations.
|
||||
|
||||
Keep product wording precise: deletion cleans sidecars; rename migrates them.
|
||||
|
||||
## Backend implementation rules
|
||||
|
||||
- Prefer standard-library and existing ComfyUI facilities; missing optional
|
||||
dependencies must fail as a localized capability, not break plugin import.
|
||||
- Use `os.path`, `pathlib`, `os.sep`, or normalization helpers for paths. Avoid
|
||||
hand-written backslash replacement.
|
||||
- Update an existing `.civitai.info` dictionary rather than overwriting it with
|
||||
a one-field object.
|
||||
- Large input collections use a JSON `POST` body. Do not place them in a query
|
||||
string or pass them as command-line arguments on Windows.
|
||||
- The private local `tests/` directory is ignored and is not imported by runtime
|
||||
code or shipped in the installable plugin.
|
||||
@@ -0,0 +1,305 @@
|
||||
# Frontend Architecture
|
||||
|
||||
Read this document for changes under `web/`, browser entry modes, panel state,
|
||||
localization, media, or explicit canvas mutations.
|
||||
|
||||
## Bootstrap and module ownership
|
||||
|
||||
The verified AMB0/AMB1 workflow share-code Import / Export Center is available
|
||||
from Toolbox through `window.AMB_WorkflowShare.showUnifiedModal()`. Both directions
|
||||
are enabled by explicit product decision. It is independent of the paused Recipe
|
||||
package import/export and closed Material Library file import. Image/workflow hash
|
||||
injection, host saving, and ordinary image downloads remain unchanged.
|
||||
|
||||
ComfyUI loads JavaScript in the extension `WEB_DIRECTORY` as ES modules.
|
||||
`web/main.js` registers `Anomalous.ModelBrowser` and coordinates host hooks.
|
||||
`browser.js` defines the browser class and binds extracted feature methods;
|
||||
`browser_entry.js` owns the single browser instance and all entry presentation;
|
||||
`interface_settings.js` owns locale and theme preferences. A syntax error or duplicate
|
||||
top-level declaration in any imported module can prevent registration and make
|
||||
the entire entry disappear. For affected modules, validate syntax and module linking
|
||||
via Node's experimental VM modules (`vm.SourceTextModule`) and verify
|
||||
the real ComfyUI runtime creates the configured entry. Floating trigger styling in
|
||||
the ordered stylesheet bundle rooted at `web/styles.css` uses dynamic `1em` SVG
|
||||
scaling and flex centering to guarantee
|
||||
consistent visual presentation across all configured trigger sizes.
|
||||
|
||||
Major UI panels live in `web/modules/ui_*.js`. Shared browser state remains on
|
||||
the `AnomalousBrowser` instance. Pure parsing, normalization, comparison, and
|
||||
transaction helpers remain in focused modules rather than acquiring DOM state.
|
||||
|
||||
`ui_sidebar.js` assembles the browser shell and folder navigation.
|
||||
`ui_settings_hub.js` owns settings and model-card preferences, while
|
||||
`ui_toolbox.js` owns the catalog, fixed shortcut actions, and tool dispatch.
|
||||
The Material Library shortcut remains a native ComfyUI command/keybinding; a
|
||||
deferred window-key fallback invokes the same command path only when the host
|
||||
did not bring the library forward, so handled shortcuts are not executed twice.
|
||||
`ui_browser_navigation.js` owns shared panel hiding, recoverable detail cleanup,
|
||||
and workspace return. `ui_scan_wizard.js` owns scan configuration and scan-launch polling,
|
||||
`ui_folder_manager.js` owns folder visibility/order and presentation-mode
|
||||
changes, and `ui_help.js` owns the help dialog. Public entry functions remain
|
||||
browser-instance methods so existing actions share current browser state.
|
||||
|
||||
Update-guide content and UI lifecycle are separate modules. The header and Help
|
||||
open it only on explicit user action; browser close disposes it. Read
|
||||
[update-guide.md](update-guide.md) before changing guide IDs, steps, persistence,
|
||||
or sidebar icon/label transitions. Do not embed guide logic in the main/sidebar
|
||||
bootstrap or reuse its content version as a feature flag.
|
||||
|
||||
The main surfaces are:
|
||||
|
||||
- Sidebar shell: navigation and persistent scan controls; focused child modules
|
||||
own the scan wizard, folder manager, and help dialog.
|
||||
- Grid and model detail: model browsing plus coordinated detail display in
|
||||
`ui_detail.js`, metadata editing in `ui_model_editor.js`, and advanced selection
|
||||
in `ui_model_selector.js`.
|
||||
- Gallery: generated outputs and the full-screen Image Detail Studio Workbench.
|
||||
`ui_gallery_detail.js` owns its singleton lifecycle and cache, `ui_image_stage.js`
|
||||
owns header/zoom/filmstrip interaction, and `ui_image_inspector.js` owns metadata tabs.
|
||||
- Workspace: Prompt Notes, Workflow Recipes, and the Material Library. Prompt Note
|
||||
catalog/persistence, editing, and canvas creation are separated across
|
||||
`ui_notebooks.js`, `ui_notebook_editor.js`, and `notebook_canvas.js`. Material
|
||||
discovery/pagination, cards, detail, and node application are separated across
|
||||
`ui_materials.js`, `ui_material_cards.js`, `ui_material_detail.js`, and
|
||||
`ui_material_application.js`.
|
||||
- Node Assistant/Model Doctor: selected-node actions, diagnostics, parameter
|
||||
presets, and missing-model recovery. `ui_doctor.js` coordinates diagnosis and
|
||||
global scans, `ui_node_assistant.js` owns assistant history, `ui_node_model_picker.js`
|
||||
owns the native-widget model replacer, and `ui_node_presets.js` owns parameter
|
||||
preset previews and application.
|
||||
|
||||
`ui_dom.js` owns generic `text` and `jsonResponse` helpers.
|
||||
`material_inspector.js` shares image metadata helpers and node-parameter rendering
|
||||
between the material and image-inspector modules. The workbench does not import
|
||||
the library UI, keeping this dependency chain acyclic. Library discovery uses
|
||||
server-side filters and pages with cancellable requests. Detail entry fetches
|
||||
metadata and scoped node blocks; the complete workflow loads only on the explicit
|
||||
open action. See `material-library.md` for the API and persistence contract.
|
||||
|
||||
## Entry modes and host integration
|
||||
|
||||
One shared `openBrowser()` command backs the floating trigger, ComfyUI action-bar
|
||||
button, and `Extensions -> Anomalous Model Browser` command. Entry presentations
|
||||
are mutually exclusive and reuse the same browser instance. The Extensions
|
||||
command remains available as a recovery path in every mode.
|
||||
|
||||
Native commands own the default `Ctrl + Shift + M` browser binding and the
|
||||
`Ctrl + Shift + L` Material Library binding. Shortcut customization delegates
|
||||
to ComfyUI's command/keybinding panel and recorder; the plugin does not install
|
||||
a parallel global keyboard listener or maintain a second shortcut preference.
|
||||
|
||||
Language changes replace the registered Interface setting descriptors with
|
||||
freshly translated copies. ComfyUI's reactive settings tree therefore updates
|
||||
the open category heading, labels, tooltips, custom controls, and combo options
|
||||
immediately without requiring a page refresh.
|
||||
|
||||
Hidden reusable dialogs must not advertise an active modal state. In particular,
|
||||
`aria-modal` is `true` only while the dialog is visible, so ComfyUI's global
|
||||
modal guard does not suppress unrelated host shortcuts.
|
||||
|
||||
## Panel and lifecycle state
|
||||
|
||||
Main content surfaces are exclusive. Opening one must hide or clean the surface
|
||||
it replaces, while preserving only the state required for an explicit return
|
||||
path. Search from the sidebar exits model detail and shows the grid. Workspace
|
||||
records the previously visible main-browser panel and restores it on close,
|
||||
falling back to the grid when the prior state is unavailable.
|
||||
|
||||
Recipe-to-model navigation is a special recoverable transition described in
|
||||
`recipes.md`. It keeps the outer browser open and must not call the browser-wide
|
||||
`close()` lifecycle.
|
||||
|
||||
Grid folder navigation removes old content rather than accumulating hidden
|
||||
grids. Before removing DOM that contains video or audio, pause the media and
|
||||
release sources where applicable. Closing the browser aborts active listing,
|
||||
disconnects observers, pauses media, and releases grid video/audio sources.
|
||||
Lightweight cards may remain warm for 90 seconds, after which card DOM and model
|
||||
payloads are released. A new listing cancels the previous request and render
|
||||
generation.
|
||||
|
||||
The full ordered model result remains available, but cards are created in
|
||||
bounded animation-frame chunks. Images use lazy loading and async decoding.
|
||||
Grid video sources are attached only near the viewport and then follow the
|
||||
configured `always` or `hover` behavior with muted, loop, and plays-inline
|
||||
attributes.
|
||||
|
||||
When identifying video media from a served URL, inspect the full URL with a
|
||||
query-aware extension test such as `/\.(mp4|webm)(?:$|\?|&|#)/i`. Do not use
|
||||
`new URL(url).pathname`, because the backend may put the actual filename in a
|
||||
query parameter.
|
||||
|
||||
## Model source component links
|
||||
|
||||
The source hub uses `inferModelFolderTypes` for workflow component labels and
|
||||
category-scoped, exact-path local metadata reads. Context requests are batched
|
||||
to respect the endpoint's 16-item limit. Missing source links and unavailable
|
||||
native model choices are independent states; dynamic choices are not treated
|
||||
as evidence of absence. Native extensionless component choices and PTH/GGUF
|
||||
references remain visible even when the backend cannot locate an individual file.
|
||||
|
||||
`model_source_data.js` owns library-result shaping and the source hub's pure
|
||||
main/component grouping and filtering. CLIP, text encoder, CLIP Vision, VAE, and
|
||||
preview-VAE entries live in a session-local, default-collapsed disclosure; the
|
||||
source-status pills and search affect both main models and components. Collapsed
|
||||
component rows are not constructed. Main-model completion statistics exclude
|
||||
optional component links, while the disclosure's missing-local count describes
|
||||
only the currently filtered component matches.
|
||||
Legacy Civitai placeholder links with non-positive model IDs are normalized to
|
||||
an unfilled source before grouping, so offline inference records remain visible
|
||||
under the source-needed filter.
|
||||
|
||||
Workflow save and Note generation always receive the complete workflow model
|
||||
set; summary copy receives only the visible rows. Such links may be saved to the
|
||||
workflow, but component sidecar writes require a resolved local target. Opening,
|
||||
filtering, and expanding the hub do not initiate cloud scans, hash calculation,
|
||||
or sidecar writes, and do not change graph/model identity recovery rules.
|
||||
|
||||
## Localization and DOM safety
|
||||
|
||||
`web/modules/locales.js` is the canonical catalog for user-visible runtime
|
||||
strings. Resolve strings at render time through the shared translator. Chinese
|
||||
and English dictionaries keep identical key sets, have no duplicate keys, and
|
||||
cover all statically referenced translation keys.
|
||||
|
||||
Dynamic filenames, paths, model names, node types, counts, and user-authored
|
||||
content remain parameters rather than dictionary entries. Insert untrusted text
|
||||
with `textContent` or escaping. Only fields intentionally supporting trusted
|
||||
formatting may pass through `setSafeRichHtml()` in `safe_dom.js`; never assign
|
||||
metadata directly to `innerHTML`.
|
||||
|
||||
Persistent localized nodes that survive a language change carry an i18n key and
|
||||
are refreshed in place or rebuilt from current state. Missing translation data
|
||||
must not prevent a panel or action from rendering.
|
||||
|
||||
Scope plugin DOM IDs and CSS classes with `anomalous-`. Before introducing a
|
||||
dynamic ID, search `styles.css` for collisions. Follow the established z-index
|
||||
tiers and ensure a child modal sits above the parent that would otherwise consume
|
||||
its clicks.
|
||||
|
||||
## Graph and widget mutation rules
|
||||
|
||||
Explicit graph mutations are transactional. Validate the entire intended change
|
||||
before mutation, wrap it in `graph.beforeChange()` / `graph.afterChange()`, and
|
||||
restore prior nodes, links, and widget values if any operation or callback fails.
|
||||
A successful change marks graph and canvas dirty and emits the host change event
|
||||
expected by dependent surfaces.
|
||||
|
||||
Partial Recipe Append clones serialized nodes, remaps IDs and links, supports
|
||||
groups, and rolls back everything it created on failure. It never calls
|
||||
`loadGraphData` on the live graph and rejects unsupported subgraph definitions
|
||||
until they have a complete remapping path. Complete recipes use
|
||||
`loadGraphData` intentionally so ComfyUI owns new-workflow canvas creation.
|
||||
|
||||
`graph_splice.js` handles deliberate MODEL/CLIP insertion. It analyzes declared
|
||||
port types rather than slot indexes or display names. Ambiguous downstream
|
||||
fan-out is rejected until a user can choose a branch. Picker candidates come
|
||||
from the target widget's native combo values; metadata may filter or decorate
|
||||
that set but may not introduce foreign category values.
|
||||
|
||||
Parameter application resolves a serialized match back to a live ComfyUI node
|
||||
before accessing widgets or callbacks. It validates all target slots first,
|
||||
skips volatile values, and calls the host's widget hooks with the current
|
||||
four-argument contract. Serialized workflow records are never treated as live
|
||||
widget objects.
|
||||
|
||||
## Optional capabilities
|
||||
|
||||
`web/hash_resolver.js` may hook a compatible graph serializer to carry model
|
||||
provenance. If the host API is unavailable, it disables only that integration
|
||||
with a useful warning. It must not prevent `main.js` from registering or remove
|
||||
the visible browser entry.
|
||||
|
||||
Network-backed enrichment is explicit and recoverable. An unavailable Civitai
|
||||
or translation service may produce a local error state; it must not block local
|
||||
browsing, editing, or already stored data.
|
||||
|
||||
## Prompt Studio ownership
|
||||
|
||||
The studio supports copying prompt text and saving combinations to the local
|
||||
Material Library. Standalone prompt-plan JSON export has been removed and the
|
||||
Material Library file-import entry is closed.
|
||||
|
||||
Source cards automatically refresh on studio open, browser focus/visibility, and
|
||||
every 30 seconds while the page is visible (scheduled after the previous request).
|
||||
Saving a studio combination also refreshes immediately. `sourceKind: 'material'`
|
||||
cards are reconciled by filename and role from a complete paginated snapshot;
|
||||
renames/content edits replace them and deleted sources disappear. Failed or
|
||||
cancelled reads retain existing cards. Equal text from different sources retains
|
||||
each source identity. Built-in and unsaved local cards remain independent.
|
||||
Library cards show their origin and have no delete action in the studio. Mixer
|
||||
blocks are editable copies; refreshing sources must not modify existing drafts.
|
||||
The deck owns its refresh timer, listeners and AbortController and releases all
|
||||
of them when its view closes. New-card role is supplied by the workbench callback.
|
||||
|
||||
The studio has one standalone drawer; the former embedded side/full composer
|
||||
and material import drawer are removed. Existing browser integration continues
|
||||
to call `openPromptStudio(owner)`, and external prompt dispatch uses
|
||||
`appendPromptToStudio(owner, text, isPositive, title)`.
|
||||
|
||||
| Module | Owned state and responsibilities |
|
||||
| --- | --- |
|
||||
| `ui_prompt_composer.js` | Active drawer, docking width/side, trigger visibility and plan-loading request |
|
||||
| `ui_prompt_workbench.js` | Owner-backed draft, active role, block editing, ordering, write/copy/save |
|
||||
| `ui_prompt_source_deck.js` | Source cards, filters, new-card form, preview popover and current sync request |
|
||||
| `ui_prompt_inspector.js` | Full-text inspection window, role tab and its translation lifetime |
|
||||
| `prompt_studio_data.js` | Starter cards, display categories, draft/block initialization and synthesis |
|
||||
| `prompt_composition.js` | Pure plan conversion, sorting and composition; no DOM ownership |
|
||||
| `prompt_material_source.js` | All-page prompt discovery, detail loading and reconciliation by source identity |
|
||||
| `ui_lifecycle.js` | View-scoped listeners, AbortSignal, cleanup callbacks and resizing |
|
||||
|
||||
Child views receive their container and narrow callbacks. The workbench keeps
|
||||
`owner.promptPlanDraft` for reopening and exposes only `updateAll` / `addBlock`
|
||||
through `owner.sidePromptComposerControl`. The source deck keeps its state local
|
||||
and returns extraction/refresh/sync actions instead of assigning new owner fields.
|
||||
|
||||
Draft `plan.parts` is the authoritative editable representation. Full-text
|
||||
inspector edits merge the enabled blocks in the edited role into one block,
|
||||
preserving disabled blocks and the opposite role. The inspector explains this
|
||||
behavior before editing. Copy, save and reopen derive text from the same parts.
|
||||
|
||||
Every close route (button, Escape, backdrop, replacement and parent close) must
|
||||
dispose the same view scope. Parent close also closes its inspector. An Escape
|
||||
handled by an inspector must not close its parent in the same event dispatch.
|
||||
Closing during resize releases move/up listeners and restores body cursor and
|
||||
selection styles. UI reads are cancelled on close; a save already sent to the
|
||||
server may still complete, but must not reopen or repaint a disposed view.
|
||||
|
||||
Initial and automatic source synchronization share a paginated loader. List
|
||||
summaries are identifiers, not prompt bodies: each unique filename is resolved
|
||||
through `material_prompt_data.js`. Reconciliation uses source filename and role,
|
||||
so equal text from different materials retains each origin. A newer sync cancels the old
|
||||
one and only the current result updates the deck.
|
||||
|
||||
The translator captures graph, node, widget and widget value before an async
|
||||
write. It revalidates that destination and the input after translation; close,
|
||||
selection changes and intervening widget edits invalidate the pending write.
|
||||
Explicit English translation applies to all input languages, including kana
|
||||
and Korean. Transport errors and rejected bridge responses remain local errors;
|
||||
provider-specific validation belongs to the backend translation route.
|
||||
|
||||
## Visual styling and theme architecture
|
||||
|
||||
`styles.css` is the single external entry and ordered import manifest for current
|
||||
visual values. Its `web/styles/00-*.css` through `10-*.css` children preserve the
|
||||
original cascade order; every child import carries the same cache version so an
|
||||
entry-cache hit cannot leave stale child rules. `tests/css_bundle_order.mjs`
|
||||
guards the unique ordered list and byte-for-byte reconstructed bundle. Shared `--amb-*`
|
||||
tokens express surfaces, text, borders and control shapes; theme overrides must
|
||||
be scoped to `.theme-abyssal-scarlet` rather than changing unrelated surfaces.
|
||||
|
||||
Studio drawer rules keep the source deck at the screen edge and the assembly
|
||||
track next to the canvas. Common geometry is shared between dock directions;
|
||||
direction-specific rules set column order and separators. The removed embedded
|
||||
composer's `#anomalous-container.anomalous-docked` overrides must not return.
|
||||
Before adding an override or `!important`, locate and edit the owning rule.
|
||||
Older component and theme overrides elsewhere in the ordered bundle still need a
|
||||
separate, visually verified consolidation.
|
||||
|
||||
## Verification
|
||||
|
||||
Use `node --experimental-vm-modules tests/prompt_ui_lifecycle.mjs` for actual
|
||||
module open/close, nested Escape, resize cleanup, block insertion and delayed
|
||||
node writes. `tests/prompt_material_source.mjs` covers pagination, deduplication
|
||||
and cancellation; `tests/translation_service_contracts.mjs` covers the HTTP
|
||||
bridge. The UI fixture simulates DOM and ComfyUI APIs and does not replace
|
||||
checking the real host, layout, focus, drag gestures and theme rendering.
|
||||
@@ -0,0 +1,333 @@
|
||||
# Material Library
|
||||
|
||||
The Material Library is a user-curated reuse layer. It does not replace
|
||||
Workflow Recipes, Prompt Notes, Node Assistant, or Model Doctor. The initial
|
||||
material kind is an image workflow snapshot captured from a generated PNG that
|
||||
contains a complete ComfyUI UI workflow.
|
||||
|
||||
## Persistence and API ownership
|
||||
|
||||
`api/material_schema.py` owns record shaping, prompt-role rules and bounded
|
||||
normalization; `api/material_assets.py` owns source inspection and private image
|
||||
copies; `api/material_store.py` is the sole owner of persistence locking, the
|
||||
summary cache, query/update/delete, and recipe-source resolution. `api/materials.py`
|
||||
owns HTTP request/response mapping and narrow compatibility entry points. Records live under
|
||||
`user/<profile>/workflows/anomalous_materials`; source PNG copies and bounded
|
||||
WebP previews live in a private `.assets/<material-stem>/` directory.
|
||||
|
||||
The backend accepts output-image descriptors only after applying the shared
|
||||
filename and containment checks. It reads bounded embedded metadata, requires a
|
||||
valid UI workflow, copies at most 64 MiB of source image data, validates the
|
||||
graph with the Recipe workflow validator, and writes JSON atomically. List
|
||||
responses omit the full workflow. The explicit image-inspection route returns
|
||||
the validated workflow plus summary node blocks only after the user opens one
|
||||
image detail. This lets the workbench display exact `widgets_values`, bounded
|
||||
node `properties`, mode, and volatile widget indexes without allocating a
|
||||
second full-PNG buffer in the browser. Direct PNG parsing remains a temporary
|
||||
compatibility fallback for an older running backend. Exact metadata is kept in
|
||||
a small 16-entry in-memory LRU, and duplicate workflow/widget fields are
|
||||
discarded before caching. Local-model preview resolution is deferred until the
|
||||
Models tab is opened and then cached with that image's metadata. Asset reads
|
||||
require both a valid material record and a contained private asset path.
|
||||
|
||||
Saving stages the image, preview, and JSON together below a private temporary
|
||||
directory. Assets are promoted before the record becomes visible. A failed final
|
||||
record commit removes only the newly promoted assets; staging is cleaned on exit.
|
||||
Existing records and their images are never overwritten by this path.
|
||||
|
||||
Material discovery caches up to 4,096 summaries, keyed by the contained record's
|
||||
real path, size, mtime, and ctime. It never caches full workflows. A directory
|
||||
inventory detects additions/deletions and re-parses only changed records. Node-type
|
||||
lookup filters summaries before opening matching workflows; asset authorization
|
||||
uses the same validated summary cache. Callers receive independent summary copies.
|
||||
|
||||
The library requests 48 summaries per page. `materials` accepts `q` (name, tags,
|
||||
or node type), `tag`, `kind`, `category`, exact `node_type`, `page`, and `limit` (at most 100), and returns
|
||||
`total`, `page`, `pages`, and the library's available `tags`. Older callers without
|
||||
page/limit retain their complete summary response. Name/tag edits use
|
||||
`update_material`, preserve the source workflow, and atomically replace the record.
|
||||
Tags are trimmed, deduplicated without case sensitivity, and limited to 20 tags
|
||||
of 60 characters each; older records without tags remain valid.
|
||||
|
||||
`category` groups sources before pagination: `workflow` contains full snapshots;
|
||||
`prompts` contains note/text/plan kinds and selections consisting only of prompt
|
||||
nodes; `params` contains other node/recipe selections. `all` is the default.
|
||||
Exact `kind` remains compatible. `material_prompt_data.js` reads authoritative
|
||||
detail fields for the studio: notes use `note.promptEn`, plans compose saved
|
||||
parts, and workflow selections use top-level `prompt_groups`. The import drawer
|
||||
pages summaries and fetches prompt text only on inspection; search covers names,
|
||||
tags and node types. Side/full editors have separate view references and share
|
||||
the active draft; closing or resetting their importer cancels pending requests.
|
||||
|
||||
Save, edit, and delete serialize their writes within the server process. Before
|
||||
publishing a new record, save compares the source PNG SHA-256, material kind, and
|
||||
selected node IDs against existing summaries. A duplicate returns HTTP 409 with
|
||||
`status: duplicate` and the existing name/filename, without leaving new assets.
|
||||
An explicit retry with `allow_duplicate: true` creates a separate copy. This is
|
||||
exact-source duplicate detection, not visual similarity or model identity.
|
||||
|
||||
The route family is:
|
||||
|
||||
- `POST /anomalous/inspect_image_material`
|
||||
- `POST /anomalous/save_image_material`
|
||||
- `POST /anomalous/save_parameter_material`
|
||||
- `POST /anomalous/save_prompt_note_material`
|
||||
- `POST /anomalous/save_prompt_plan`
|
||||
- `GET /anomalous/materials`
|
||||
- `GET /anomalous/material_full`
|
||||
- `GET /anomalous/material_asset`
|
||||
- `GET /anomalous/materials/by_node_type`
|
||||
- `POST /anomalous/update_material`
|
||||
- `POST /anomalous/delete_material`
|
||||
|
||||
## Frontend ownership
|
||||
|
||||
`ui_materials.js` owns Workspace discovery, filters, pagination and CRUD
|
||||
coordination; `ui_material_cards.js` owns summary cards; `ui_material_detail.js`
|
||||
owns names/tags and full-workflow detail/handoff; `ui_material_application.js`
|
||||
owns selected-node tracking and explicit application. `ui_gallery_detail.js`
|
||||
owns the single image-workbench lifecycle/cache, while `ui_image_stage.js` owns
|
||||
media/zoom/filmstrip interaction and `ui_image_inspector.js` owns image/node
|
||||
inspection and saving. These views use `material_inspector.js` for shared metadata helpers and exact node
|
||||
parameter rendering; the workbench does not import the library UI.
|
||||
`ui_gallery.js` and
|
||||
`ui_recipe_detail.js` only supply non-invasive gallery entry points. Main
|
||||
Gallery image clicks open the focused pan/zoom viewer, while the dedicated
|
||||
parameter action opens the Image Detail Studio. Drag, delete, and cover-selection
|
||||
behavior remains independent of those two entry points.
|
||||
`ui_node_presets.js` presents Node Assistant presets; it shares node application
|
||||
with the library through `ui_material_application.js` and `node_material_actions.js`.
|
||||
|
||||
`ui_recipe_detail.js` can publish the active Recipe parameters or the selected
|
||||
Parameter Notebook as `recipe_parameter_selection`. The primary panel saves all
|
||||
reusable widget-bearing nodes; the raw-node inspector supports direct single-node
|
||||
save and explicit multi-selection. Prompt cards expose the same direct save.
|
||||
The backend reloads the named Recipe/Parameter Notebook from its contained user
|
||||
directory instead of trusting a browser-supplied workflow.
|
||||
|
||||
Material cards remain compact, summary-only discovery items. “View Details”
|
||||
switches the library itself to a master-detail inspector: a contained reference
|
||||
image stays on the left, while scope, model references, and reusable node blocks
|
||||
are grouped on the right; exact widget values remain nested under each node.
|
||||
Only the opened material fetches `material_full?include_workflow=0`: metadata and
|
||||
scoped node blocks, with no complete source workflow. Only prompt cards initially
|
||||
expand; other node cards build their parameter DOM on first expansion and reuse
|
||||
it on later toggles. Expand/collapse-all follows the actual card state. Selectable
|
||||
cards in the image workbench remain closed initially.
|
||||
Returning to the list aborts
|
||||
an unfinished request and releases the detail payload/DOM so browsing never
|
||||
accumulates full workflows in browser memory.
|
||||
|
||||
Opening a complete snapshot explicitly requests `include_workflow=1`, which
|
||||
returns the original workflow and an empty `node_blocks` array to avoid duplicate
|
||||
widget payloads. The original seed and hash evidence remain intact. The server
|
||||
reads, shapes, and serializes these responses in a worker thread. Selected-node
|
||||
records filter before copying widget values; they never return a full workflow
|
||||
and reject explicit workflow requests with HTTP 403. For legacy callers, omitting
|
||||
the flag retains both workflow and node blocks for complete, openable snapshots.
|
||||
Only `0`, `1`, or an omitted flag are accepted.
|
||||
|
||||
Search is debounced and each list request cancels its predecessor. Changing a
|
||||
filter resets the page; returning from detail preserves the current filters.
|
||||
Card activation supports Enter/Space as well as mouse clicks. A name/tag edit
|
||||
refreshes summary cards and filter choices. Shared confirmation dialogs sit above
|
||||
the image workbench, whose keyboard shortcuts yield while a dialog is open.
|
||||
|
||||
A full material workflow is exact and retains its seed. Node-sized reuse is a
|
||||
preset operation and therefore uses the existing transactional parameter
|
||||
application path, which skips known volatile seed widgets. The lookup endpoint
|
||||
filters blocks by exact `node.type`; when more than one source node matches, the
|
||||
user chooses the block explicitly.
|
||||
|
||||
Every node card in the image workbench can be saved directly as an
|
||||
`image_node_selection`; checking several cards exposes one colocated save action
|
||||
above the node list. The initially hidden footer remains dedicated to the full image and
|
||||
workflow snapshot rather than mixing both concepts in a scope selector. The
|
||||
original workflow remains in a selected-node record as source provenance, but
|
||||
list/count/lookup APIs expose only the selected blocks. Such a material
|
||||
deliberately lacks `open_workflow`; the library and Node Assistant apply its
|
||||
parameters to one node without opening the hidden source workflow.
|
||||
|
||||
The image workbench also exposes saving on its primary surfaces. The top action
|
||||
reveals the full-snapshot name/tag form. One generation-settings action captures
|
||||
the sampler and size nodes together; each prompt node can be saved without visiting
|
||||
the all-nodes tab. A metric action is intentionally node-sized: it does not claim
|
||||
to persist one isolated widget value. The former plain-text “share text” action
|
||||
is not part of this workbench.
|
||||
|
||||
Prompt roles are inferred from workflow topology first, with node-title hints as
|
||||
a fallback. Image details display the inferred role beside every prompt node and
|
||||
allow an explicit positive, negative, shared, unknown, or ignored override.
|
||||
Overrides travel into the saved material but never write back to a source Recipe.
|
||||
Material details expose the same selector; `update_material` atomically persists
|
||||
or clears these overrides, and choosing Automatic restores the topology result.
|
||||
|
||||
## Model identity handoff
|
||||
|
||||
Material storage preserves the workflow's existing `extra.anomalous_hashes`.
|
||||
When one node block is applied, records scoped to the source node ID are copied
|
||||
to the target node ID. This is evidence transport, not identity resolution.
|
||||
Model Doctor remains the authority that decides whether a missing model may be
|
||||
repaired. A material name, preview, saved path, or size is never promoted to
|
||||
cryptographic identity.
|
||||
|
||||
## Compatibility and future material kinds
|
||||
|
||||
Records declare `schema_version`, `kind`, and `capabilities`. New sources such
|
||||
as Recipe selections and Prompt Note blocks should add explicit kinds or
|
||||
source metadata without weakening the image snapshot contract. Existing direct
|
||||
Recipe and Prompt Note use paths remain available; the library is optional
|
||||
curation rather than a mandatory intermediary.
|
||||
|
||||
The currently saved kinds are `image_workflow_snapshot`, `image_node_selection`,
|
||||
and `recipe_parameter_selection`. Recipe parameter materials intentionally have
|
||||
no image asset and only advertise `apply_node_parameters`; they cannot replace
|
||||
the canvas with the source Recipe. A prompt badge on a CLIPTextEncode selection
|
||||
still describes a workflow prompt node, not a Prompt Note import. Prompt Notes use the explicit kinds described below. The `reference_image` capability currently
|
||||
means a preserved image that can be viewed; copying it into ComfyUI input or
|
||||
configuring LoadImage is also future work.
|
||||
|
||||
## Prompt Note capture and return
|
||||
|
||||
`POST save_prompt_note_material` accepts `notebook_filename` as a provenance
|
||||
label, `name`, optional `tags`, `scope` (`note` or `prompt`), and an immutable
|
||||
client snapshot in `note`. It does not use that filename to read a file. The
|
||||
snapshot includes the latest text before the editor's autosave timer fires.
|
||||
The server bounds it to the notebook's 2 MiB limit and retains only the supported
|
||||
prompt, translation, language, base-model, main-model and LoRA fields. Model
|
||||
objects remain saved selections, never identity evidence. Prompt-only capture
|
||||
excludes all model fields. `promptZh` is a translation, not a negative prompt.
|
||||
|
||||
- `prompt_note_bundle`: entire note, translations and companion model selections.
|
||||
- `prompt_text`: prompt text and translations without companion models.
|
||||
|
||||
Both kinds advertise `copy_prompt` and `restore_prompt_note`, contain no image
|
||||
assets or synthetic workflow, and never appear in node-type lookup. Explicit
|
||||
full-workflow requests return 403. Atomic creation and duplicate confirmation
|
||||
reuse the image-free material writer; deduplication compares content signature
|
||||
and kind. Editing a note later cannot alter its captured material.
|
||||
|
||||
The library copies prompt text or loads the snapshot as a newly named, uniquely
|
||||
identified Prompt Note. It flushes any pending note before switching, writes the
|
||||
new record successfully before navigation, and preserves both original note and
|
||||
material. Canvas use remains the existing explicit Prompt Note action.
|
||||
|
||||
`material_feedback.js` supplies a shared save receipt with View Material.
|
||||
`web/main.js` binds `openSavedMaterial` from `ui_materials.js`; it owns navigation
|
||||
and workspace return state, keeping workbench imports acyclic. Image details,
|
||||
recipes and Prompt Notes use that same handoff. Node-sized receipts lead to a
|
||||
detail that explains reuse through the library or Node Assistant.
|
||||
|
||||
Primary surfaces use progressive disclosure: image save form, prompt role
|
||||
selectors, technical node metadata, companion models, recipe export/source
|
||||
editing, and recipe multi-selection are revealed on request. Parameter-page
|
||||
metrics appear once with exact-value copy controls. Display wording is Parameter
|
||||
Sets (参数方案); existing notebook routes and storage identifiers stay stable.
|
||||
|
||||
|
||||
## Entry points and shared application
|
||||
|
||||
The lower-left control opens a standalone Material Library container, with no
|
||||
workspace tabs and no initial Prompt Note fetch. The upper-right control opens
|
||||
Workflow Recipes; that workspace retains its Recipes and Prompt Notes tabs.
|
||||
Both containers share the browser's panel area but have separate headers and
|
||||
lifecycle state; switching restores the appropriate container. Material Library
|
||||
file import and its transfer-center entry are closed. It has no independent
|
||||
image-material bundle exporter. The verified workflow share-code Import / Export
|
||||
Center lives in Toolbox. Recipe package import/export remains paused, while prompt-plan
|
||||
JSON export has been removed. Local capture/save actions remain available.
|
||||
|
||||
Opening the library with exactly one live selected node enables apply mode.
|
||||
Summary requests filter by exact `node_type`; activating a card fetches only that
|
||||
material's scoped detail. No selection means normal detail browsing. An explicit
|
||||
toggle returns to browsing all materials; prompt-only kinds also use browse mode.
|
||||
Multiple source blocks of the same type require choosing one block. The full
|
||||
workflow quick-open button is omitted from apply-mode cards.
|
||||
|
||||
`node_material_actions.js` is the shared, UI-independent mutation owner. It checks
|
||||
live graph/node identity, indexes, value types and native combo choices before
|
||||
editing. It skips seed widgets, preserves node identity/links/position, calls the
|
||||
live widget callback and four-argument node hook, and marks the graph dirty.
|
||||
Values, serialized widget values and target-scoped hash evidence change in one
|
||||
before/after transaction; hook failures restore their snapshots. Model paths must
|
||||
already be available in the native combo. This path does not invoke global model
|
||||
repair after application. Hash transport remains evidence, never verification.
|
||||
|
||||
`material_full?include_workflow=0` includes `workflow_hashes` restricted to returned
|
||||
blocks, without exposing the hidden workflow. Both library and assistant show the
|
||||
same application receipt. Undo restores values and scoped hashes only while the
|
||||
same live node, values, serialized values and target hashes still match the applied
|
||||
state. Later edits are protected. Unsupported third-party widget side effects
|
||||
still require real-host compatibility testing.
|
||||
|
||||
Selection hooks chain the host's callbacks and batch updates in a microtask;
|
||||
there is no polling. List browsing retains 48-item pagination and cancellation.
|
||||
Language changes rebuild visible library/composer text while keeping the current
|
||||
draft. Node labels reuse ComfyUI's registered localized titles with raw type fallback.
|
||||
|
||||
## Prompt combinations
|
||||
|
||||
`prompt_plan` is an image-free material with `compose_prompt` capability and no
|
||||
synthetic workflow. `POST save_prompt_plan` accepts a name, tags, and `plan`:
|
||||
`parts` is an ordered list of up to 100 records with `name` (up to 120 characters),
|
||||
`category` (`general` or `specific`), `enabled`, `positive`, and `negative`.
|
||||
Top-level `positive` and `negative` hold prompt text. The plan is bounded to
|
||||
2 MiB; unknown fields are discarded. Atomic writes and explicit duplicate-copy
|
||||
confirmation reuse the material persistence lock and canonical content signature.
|
||||
|
||||
The current panel has simple positive/negative text fields and a beginning/end
|
||||
drop-position selector. Fragment categories, ordering and enable controls are
|
||||
deferred. Opening an older plan joins its enabled fragments and final content
|
||||
into those two fields; a new save has empty `parts`, leaving the original record
|
||||
intact. Text insertion preserves weights, commas, duplicates and original nonblank
|
||||
string whitespace. A Prompt Note's `promptZh` translation is never inferred as
|
||||
negative text. Captured notes and classified workflow prompt groups can populate
|
||||
the fields; unknown workflow roles are not silently assigned a role.
|
||||
|
||||
Saving creates an independent new snapshot. Studio source cards automatically
|
||||
follow library changes; assembled draft blocks remain independent copies without
|
||||
model binding. A page-session draft survives library navigation and is replaced
|
||||
by an existing saved plan only after confirmation; it must be saved before page
|
||||
reload. Stale detail requests cannot replace the current draft. Standalone JSON
|
||||
export has been removed, and the file-import entry is closed. The shared save
|
||||
endpoint still supports local combination saves. The panel also exposes role and target-widget selection,
|
||||
beginning/end buttons for a selected node, copy controls and guarded undo.
|
||||
|
||||
## Canvas drag and drop
|
||||
|
||||
`material_drag.js` binds native drag handles to all material cards and to each
|
||||
prompt field's drag button (`bindPolymorphicMaterialCardDrag`). Only an active,
|
||||
same-page drag is trusted; transfer data is a marker, not an external mutation
|
||||
command. During drag, the browser window becomes transparent and stops intercepting
|
||||
pointer events. End, drop, Escape and loss of focus restore it and remove temporary
|
||||
event listeners. There is no persistent drag polling or detail request during hover.
|
||||
|
||||
Hit testing evaluates live node target acceptance before canvas blank surface drop.
|
||||
Because nodes sit on the canvas element, testing canvas surface first would hijack
|
||||
node drops; evaluating `accepts(node, data)` first guarantees node targeting takes
|
||||
precedence. Live node lookup uses ComfyUI canvas coordinate conversion (including pan/zoom),
|
||||
canvas bounds, and graph node hit-testing. DOM-widget surfaces are accepted. The
|
||||
graph/canvas identities are captured at drag start and checked again at drop. The actual
|
||||
drop node is the target, regardless of the previously selected node. Receipts name
|
||||
that target and provide the shared guarded undo.
|
||||
|
||||
When dropped onto a compatible node:
|
||||
- Parameter-bearing materials fetch scoped node blocks only after drop and replace
|
||||
compatible widget values through the transactional parameter path, preserving seed,
|
||||
node position, and links.
|
||||
- Prompt materials inject prompt text into target nodes via semantic prompt widget
|
||||
sniffing (`customtext`, `multiline`, `text_g`, `text_l`, `prompt`, `positive`,
|
||||
`negative`, and localized labels), relaxing strict node-type matching for third-party
|
||||
text nodes. Text is cleanly inserted without synthetic prefixes ("负向:" / "正向:").
|
||||
|
||||
When dropped onto blank canvas:
|
||||
- Workflow materials (`image_workflow_snapshot`) trigger full workflow loading
|
||||
via `openMaterialWorkflow`.
|
||||
- Prompt materials (prompt notes, prompt plans, prompt node selections) auto-instantiate
|
||||
native `CLIPTextEncode` nodes at canvas drop coordinates, populating the pure prompt
|
||||
text, setting bilingual titles (`CLIP Text Encode (Negative/Positive)` /
|
||||
`CLIP 文本编码器 (负向/正向)`), and applying standard LiteGraph dark theme colors
|
||||
(`#532323` dark red for negative, `#235327` dark green for positive).
|
||||
|
||||
Empty/unsupported targets and canceled drags do not mutate the graph. Neither drop path
|
||||
queues generation.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Model Identity and Scanning
|
||||
|
||||
Read this document for Model Doctor, workflow provenance, missing-model recovery,
|
||||
hash caches, Civitai metadata resolution, or deep scanning.
|
||||
|
||||
## Identity boundary
|
||||
|
||||
Model Doctor recovers the same physical model referenced by provenance embedded
|
||||
in a workflow or image. Local renames and path differences are the problem it
|
||||
solves, so names cannot also be its proof.
|
||||
|
||||
Allowed automatic evidence is:
|
||||
|
||||
1. plugin-carried cryptographic hash;
|
||||
2. exact physical byte size only as a disambiguator for that hash;
|
||||
3. the model category required by the target widget.
|
||||
|
||||
Without a hash, one unique in-category size match is only a candidate. Model
|
||||
Doctor may show it during an explicit manual check, but it cannot redirect the
|
||||
node until the user confirms that candidate. Confirmation applies to the current
|
||||
node only; it does not create a persistent binding. If both hash and size are
|
||||
present and point to conflicting physical files (both match distinct local files),
|
||||
resolution reports an identity conflict and is rejected. When a requested model hash
|
||||
simply does not exist in any local file, it returns `{"found": False}` cleanly without
|
||||
falsely flagging a conflict.
|
||||
|
||||
Paths, filenames, source filenames, display/custom names, previews, workflow
|
||||
fingerprints, and fuzzy/visual similarity are never candidate evidence. They may
|
||||
be used only after identity is established to return a local dropdown value,
|
||||
locate presentation media, and verify the value against ComfyUI's native choices.
|
||||
|
||||
Foundation components—`vae`, `vae_approx`, `clip`, `text_encoders`, and
|
||||
`clip_vision`—are hash-only automatic-recovery categories. Byte size alone
|
||||
cannot automatically repair them. When size provenance is available it may be
|
||||
shown as the same explicit manual candidate, but a supplied hash mismatch never
|
||||
falls back to a filename or size-only guess.
|
||||
This is a Model Doctor confidence boundary, not a scanner-support boundary. The
|
||||
scan wizard may traverse any active registered model folder and can still
|
||||
calculate a local hash when Civitai has no matching record. Sparse or ambiguous
|
||||
remote metadata is a reason to require cryptographic identity, not to exclude
|
||||
the category from scanning.
|
||||
|
||||
An existing native combo value remains loadable even if a foundation component's
|
||||
current local hash differs from stored provenance. Model Doctor shows a
|
||||
non-blocking identity-change warning instead of declaring the node missing or
|
||||
replacing it. If the value is absent, redirection requires one exact in-category
|
||||
hash match. Missing or ambiguous evidence remains unresolved for manual action.
|
||||
|
||||
Slash normalization of a value that already has an exact native-option
|
||||
equivalent is representation normalization, not discovery. The resolver must
|
||||
never append a foreign or cross-category value to `widget.options.values` merely
|
||||
to claim success.
|
||||
|
||||
## Frontend provenance cache and injection
|
||||
|
||||
`window.anomalous_hash_cache` maps model widget values to exact SHA-256 and size
|
||||
records. It is fetched by the optional resolver from `/anomalous/all_hashes`.
|
||||
Relative-path and basename aliases exist only when unambiguous; if two files
|
||||
share an alias but differ in identity, omit that alias. Lookups prefer the full
|
||||
widget value before basename fallback.
|
||||
|
||||
After a scan writes new metadata, `window.anomalous_reload_hashes()` refreshes
|
||||
the frontend cache.
|
||||
|
||||
When provenance injection is enabled, the compatible active graph constructor's
|
||||
`serialize` path writes
|
||||
`extraObj.anomalous_hashes[node_id_filename] = {hash, size}` for known model
|
||||
widgets. The cache covers ordinary models and foundation categories. A
|
||||
foundation component without a recorded hash receives no size-only provenance.
|
||||
A missing compatible graph API disables only injection and recovery integration,
|
||||
not the main browser.
|
||||
|
||||
Exact-path checks are limited to preflight confirmation that an already resolved
|
||||
local value exists. Backend discovery constrains candidates to the inferred
|
||||
category and intersects saved hash/size evidence. One unique in-category
|
||||
size-only match is returned as a confirmation-required candidate; it is never
|
||||
an automatic result. Multiple equal-sized candidates remain unresolved. A real
|
||||
hash/size conflict is rejected.
|
||||
|
||||
## Metadata association
|
||||
|
||||
A Civitai `.info` record can describe multiple physical files, such as a model,
|
||||
text encoder, and VAE. `get_metadata()` selects the matching `files[]` entry by
|
||||
exact physical byte size. An unmatched entry may be used only when it is the sole
|
||||
hash candidate. Never take the first SHA-256 or select among entries by filename.
|
||||
|
||||
Metadata refresh is enrichment, not identity mutation. Offline generated
|
||||
metadata includes the discovered hash, but consumers still associate the record
|
||||
with the current physical file through the established size/name rules; array
|
||||
position is not identity.
|
||||
|
||||
## Deep scanning
|
||||
|
||||
Deep Hash Scan runs outside the aiohttp event loop and identifies a model through
|
||||
the established fallback sequence:
|
||||
|
||||
1. use existing valid file SHA-256 metadata when the scan does not request refresh;
|
||||
2. otherwise calculate the complete file SHA-256, never a ModelSpec header digest;
|
||||
3. if the remote service has no record, infer a bounded local base-model family
|
||||
from tensor/header fingerprints and write offline metadata.
|
||||
|
||||
`model_identity.py` owns digest validation and the `anomalous_file_identity`
|
||||
sidecar record: algorithm, file scope, digest, physical size/mtime, and computed
|
||||
source. ModelSpec hashes may describe tensor content and are never full-file
|
||||
identity ([specification](https://github.com/Stability-AI/ModelSpec)). Old locally
|
||||
inferred sidecars without this record keep their display data but need an explicit
|
||||
scan or successful on-demand SHA-256 check before supplying identity again.
|
||||
Successful on-demand verification preserves existing notes and remote metadata.
|
||||
Imported workflow provenance stays intact; it must match valid local file evidence.
|
||||
|
||||
Remote metadata requests are part of an explicit user-initiated scan. Local
|
||||
browsing and offline inference remain usable when the service is unavailable.
|
||||
|
||||
Physical rename conflicts are non-destructive. If the generated target filename
|
||||
already exists, hash both complete files. Deletion is allowed only when their
|
||||
full SHA-256 values match. Distinct files with the same generated display name
|
||||
are both preserved.
|
||||
|
||||
## Resolution execution
|
||||
|
||||
The frontend finds unresolved workflow nodes and sends provenance hash, byte
|
||||
size, and inferred model category to the batch endpoint, falling back to the
|
||||
single-item endpoint if batching fails. Requests are grouped by the exact model
|
||||
category tuple so the backend scans each group once, but every result retains
|
||||
the same hash, size, category, conflict, and ambiguity rules.
|
||||
|
||||
If a size-selected candidate lacks cached hash metadata, the backend may hash it
|
||||
on demand. Only an exact hash match establishes identity; successful discovery
|
||||
may write offline metadata for later reuse.
|
||||
|
||||
After a hash match—or explicit confirmation of a size-only candidate—the
|
||||
frontend refreshes ComfyUI's native combo definitions and accepts the returned
|
||||
path only if it is present in the target widget's choices. Then it updates the
|
||||
dropdown and clears the missing-model presentation. Background checks may
|
||||
surface size candidates but never prompt for or apply them.
|
||||
|
||||
Provenance-rich workflows skip the redundant full filename-to-hash cache refresh.
|
||||
Legacy workflows without injected provenance may refresh it for compatibility.
|
||||
This is an I/O optimization only and does not change identity evidence.
|
||||
|
||||
Civitai lookup by hash may resolve a model/version page and route mature content
|
||||
to the configured domain. Hash-detail UI may compare and copy workflow and local
|
||||
hash/size values. Neither presentation feature changes recovery rules.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Workflow Recipe Model Lifecycle
|
||||
|
||||
Read this document before changing Workflow Recipe model cards, local matching,
|
||||
partial-canvas append, package export, or Model Doctor integration. UI code may
|
||||
change freely, but the data ownership and transition rules below are contracts.
|
||||
|
||||
## Three representations with different jobs
|
||||
|
||||
A saved recipe keeps model information in three related representations:
|
||||
|
||||
1. `workflow.nodes[].widgets_values[]` is authoritative for the value ComfyUI
|
||||
will load into a model widget.
|
||||
2. `params.model_references[]` is the structured recipe record used by the UI,
|
||||
package system, history, and matching actions.
|
||||
3. `workflow.extra.anomalous_hashes` is Model Doctor provenance keyed to the
|
||||
serialized node ID and widget value.
|
||||
|
||||
Changing only one representation creates a broken recipe. A path change must
|
||||
update the workflow widget, its model reference, and its node-scoped Hash key in
|
||||
the same recipe update. A partial append must remap the Hash key alongside the
|
||||
node ID. Presentation fields must never become identity evidence.
|
||||
|
||||
## Schema v7 model reference
|
||||
|
||||
`params.model_references[]` contains these persisted fields:
|
||||
|
||||
- `node_id`, `node_type`, `node_title`: serialized graph location and display
|
||||
context;
|
||||
- `widget_index`, `widget_name`: authoritative widget slot;
|
||||
- `saved_value`: exact dropdown/path value stored in the workflow;
|
||||
- `category`, `base_model`: bounded matching and presentation context;
|
||||
- `identity`: `status`, optional SHA-256, optional byte size, and provenance;
|
||||
- `origin`: editable official-source metadata, independent of identity;
|
||||
- `preview`: optional recipe-owned bounded snapshot descriptor;
|
||||
- `user_note`: optional recipe-scoped personal note, at most 1000 characters.
|
||||
|
||||
`user_note` is not global model metadata, is not used for matching, and does not
|
||||
change Model Doctor confidence. Enrichment preserves it by the complete model
|
||||
reference key. Package export explicitly asks whether to include it; excluding
|
||||
it removes the field only from the exported current/history records.
|
||||
|
||||
Runtime-only fields such as `localModel`, `localMatch`,
|
||||
`currentAvailability`, and `currentPreviewUrl` must not be persisted as schema.
|
||||
|
||||
## Hash record shape and keys
|
||||
|
||||
The canonical record is compatible with:
|
||||
|
||||
```json
|
||||
{
|
||||
"hash": "lowercase SHA-256 or an empty string",
|
||||
"size": 123456
|
||||
}
|
||||
```
|
||||
|
||||
Node-scoped keys use `<node_id>_<saved_widget_value>`. Slash-normalized aliases
|
||||
may coexist for Windows compatibility. Readers check node-scoped keys first;
|
||||
global path aliases are legacy fallback only and must not be relied on when
|
||||
mutating a recipe.
|
||||
|
||||
Graph serialization prefers current verified local-cache data. When a widget
|
||||
value is still missing—or no current cache record exists—it must preserve the
|
||||
node-scoped provenance already carried by the graph. Clearing it would make a
|
||||
successfully appended partial recipe lose its Model Doctor identity on the next
|
||||
save.
|
||||
|
||||
An empty Hash with a known size represents unverified provenance. It may produce
|
||||
a manual same-size candidate, but never an automatic repair. A Hash copied from
|
||||
the original recipe must not be attached to a size-only candidate.
|
||||
|
||||
## Lifecycle transitions
|
||||
|
||||
### Save and update
|
||||
|
||||
The frontend captures the graph once. The backend validates it, derives model
|
||||
references, preserves compatible identity/origin/preview/user-note fields, and
|
||||
writes schema v7 atomically. Optional save-time verification may calculate a
|
||||
full SHA-256. Normal enrichment does not silently replace imported identity.
|
||||
|
||||
### Availability and local matching
|
||||
|
||||
Availability and previews describe the current computer only. Matching sends
|
||||
saved Hash/size/category evidence to Model Doctor:
|
||||
|
||||
- exact Hash match: may be offered for explicit recipe application;
|
||||
- unique size-only match: confirmation-required candidate;
|
||||
- Hash/size conflict: rejected;
|
||||
- ambiguity: unresolved.
|
||||
|
||||
Finding a candidate is presentation-only. The recipe is not changed until the
|
||||
user activates its Apply/Confirm action.
|
||||
|
||||
### Apply local match
|
||||
|
||||
One full-recipe update performs all of these changes together:
|
||||
|
||||
1. replace the target `widgets_values[widget_index]` with the local dropdown
|
||||
value;
|
||||
2. replace `model_references[].saved_value` and its identity;
|
||||
3. update `params.baseModel` when it held the same old value;
|
||||
4. remove old node-scoped Hash aliases and write aliases for the new value;
|
||||
5. archive the previous recipe through the normal update endpoint.
|
||||
|
||||
For a Hash match, the stored local identity retains the proven SHA-256. For a
|
||||
manually confirmed size candidate, use a valid Hash from the local candidate's
|
||||
metadata when available; otherwise store size-only unverified provenance. The
|
||||
author's editable `origin` and the recipe-scoped `user_note` remain intact.
|
||||
|
||||
### Apply to canvas
|
||||
|
||||
Complete recipes pass their cloned workflow to ComfyUI, so the contained
|
||||
`extra.anomalous_hashes` opens with the new workflow canvas.
|
||||
|
||||
Partial recipes are a merge transaction. New collision-free node IDs are
|
||||
allocated, links and groups are restored, and node-scoped Hash records are
|
||||
copied under the new IDs without deleting current-canvas records. If any part
|
||||
fails, inserted nodes/groups and the previous Hash map are restored together.
|
||||
|
||||
### Export and import
|
||||
|
||||
Export choices independently control preview snapshots, history, model
|
||||
identity, and personal model notes. Removing identity or notes sanitizes only
|
||||
the package clone. Import validates the contained recipe and assets, then uses
|
||||
the same normalization and enrichment boundaries as local recipes.
|
||||
|
||||
## Implementation map for UI refactors
|
||||
|
||||
- `api/recipes.py`: schema validation, enrichment, field preservation, history;
|
||||
- `api/recipe_packages.py`: export privacy and package boundaries;
|
||||
- `api/models.py`: Model Doctor evidence and candidate policy;
|
||||
- `web/modules/recipe_identity.js`: model-reference adapters for presentation;
|
||||
- `web/modules/recipe_provenance.js`: pure Hash lookup/remap/replace helpers;
|
||||
- `web/modules/recipe_actions.js`: canvas transactions and append rollback;
|
||||
- `web/modules/ui_recipe_detail.js`: user actions that invoke the contracts;
|
||||
- `web/modules/ui_recipes.js`: save/import/export orchestration;
|
||||
- `web/modules/recipe_diff.js`: history-visible semantic changes.
|
||||
|
||||
When restructuring UI, call these data functions rather than recreating Hash
|
||||
keys, matching confidence, or append behavior inside components. Filenames,
|
||||
notes, previews, and official names must never be promoted to identity proof.
|
||||
@@ -0,0 +1,231 @@
|
||||
# Workflow Recipes and Parameter Notebooks
|
||||
|
||||
Read this document for recipe schemas, cards/detail behavior, package handling,
|
||||
result galleries, Parameter Notebooks, prompt roles, and the recipe-powered Node
|
||||
Assistant.
|
||||
|
||||
## Package import/export availability
|
||||
|
||||
Recipe package import and export are temporarily closed pending validation.
|
||||
Topbar/card/detail buttons are disabled with a localized explanation. The registered
|
||||
`POST /anomalous/export_recipe_package` route returns HTTP 503 with code
|
||||
`recipe_export_disabled` before reading request JSON or recipe files.
|
||||
`RECIPE_PACKAGE_EXPORT_ENABLED` in `api/recipe_packages.py` is a release gate,
|
||||
not a user setting. `RECIPE_PACKAGE_IMPORT_ENABLED` likewise gates both
|
||||
`POST /anomalous/import_recipe_package_inspect` and
|
||||
`POST /anomalous/import_recipe_package_commit`: each returns HTTP 503 with code
|
||||
`recipe_import_disabled` before reading uploaded bytes, request JSON, inspection
|
||||
tokens or writing files. Package format helpers remain for compatibility tests;
|
||||
local saves are unaffected.
|
||||
|
||||
Reopening requires an explicit release decision, package round-trip and failure
|
||||
validation, then restoring the frontend export action and enabling the backend
|
||||
gates together. Do not reopen package transfers as a side effect of UX work. Workflow
|
||||
share-code import/export is a separate, verified feature available from Toolbox.
|
||||
|
||||
## Product and data model
|
||||
|
||||
Workspace contains Prompt Notes, Workflow Recipes, and the Material Library.
|
||||
Recipe Parameter Notebooks are presented as Parameter Sets (参数方案). Internal
|
||||
notebook route and property names may remain stable for compatibility even when
|
||||
the user-facing presentation changes.
|
||||
|
||||
A Workflow Recipe stores the authoritative serialized graph plus bounded
|
||||
metadata for browsing and comparison. Summary adapters expose common model,
|
||||
LoRA, prompt, and sampling semantics; generic bounded widget summaries cover
|
||||
other nodes, including third-party nodes. Summaries are presentation only. Edit,
|
||||
apply, structural validation, and detailed values resolve from the contained
|
||||
serialized workflow by node ID and widget index.
|
||||
|
||||
The card API stays lightweight. Full workflow and history payloads are loaded on
|
||||
demand. Every update archives the previous full recipe locally, bounded to 20
|
||||
versions. The structural fingerprint (`sha256-structural-v1`) is an integrity
|
||||
and version-comparison value, not model identity evidence.
|
||||
|
||||
Backend ownership is split by responsibility: `workflow_schema.py` owns graph
|
||||
validation, fingerprints, signatures and integrity receipts; `recipe_schema.py`
|
||||
owns recipe/model-reference normalization; `recipe_images.py` owns source images,
|
||||
covers and output-gallery inspection; `recipe_store.py` owns directory, record and
|
||||
history I/O. `recipes.py` maps HTTP requests and errors and retains narrow
|
||||
compatibility exports only. `recipe_packages.py`, `parameters.py`, and materials
|
||||
import those owners directly rather than reaching through the route facade.
|
||||
|
||||
The current persisted recipe schema is v7. Earlier schema steps introduced the
|
||||
structural fingerprint, explicit model-reference identity records, and optional
|
||||
recipe-owned preview descriptors; v5 separates model identity from editable
|
||||
official-origin fields. V6 records whether the saved graph is a partial or
|
||||
complete recipe. V7 adds bounded recipe-scoped model notes and makes Hash
|
||||
synchronization across local matching and partial append an explicit invariant.
|
||||
Normal save/update paths preserve compatible imported
|
||||
records rather than rebuilding identity from the current machine. An explicit
|
||||
save-time verification choice may replace missing identity with a freshly
|
||||
computed SHA-256 for supported model categories.
|
||||
|
||||
Recipe model references separate saved identity from current-machine
|
||||
availability and official origin metadata. Origin refresh is an explicit
|
||||
enrichment mode. A refresh-only request clones the stored recipe before
|
||||
enrichment so it cannot be rejected by complete-recipe validation or discard the
|
||||
workflow. Normal updates preserve imported identity/origin records when the
|
||||
current machine cannot resolve them.
|
||||
|
||||
## Save, edit, and presentation
|
||||
|
||||
Save captures the live graph exactly once before opening the dialog; that fixed
|
||||
snapshot is the graph sent to the backend. The dialog records recipe identity,
|
||||
notes, tags, and cover/source image. It does not ask users to select presentation
|
||||
pins or a per-recipe snapshot policy. Existing `params.pinned` values survive an
|
||||
edit, while new recipes use an empty list.
|
||||
|
||||
Before the dialog opens, an advisory check reads only the captured workflow hash
|
||||
records and cached local metadata. Recognized model references without verified
|
||||
identity appear as an optional action. The checkbox is off by default; enabling
|
||||
it computes the exact full-file SHA-256 in a worker thread during persistence.
|
||||
Foundation components such as VAE and text encoders are included because Model
|
||||
Doctor requires their hash for automatic recovery and treats size-only evidence
|
||||
as a manual candidate at most.
|
||||
Inspection failure never blocks the workflow snapshot from being saved.
|
||||
|
||||
The detail view contains Overview, Parameters, Gallery, and Versions as
|
||||
applicable. Compact cards may ellipsize bounded values while preserving their
|
||||
full copy value. Detail rows provide visible expand/collapse and copy controls
|
||||
for long values and prompts; they do not silently truncate authoritative data.
|
||||
|
||||
`ui_recipe_detail.js` coordinates the detail session, active tab, and model composition.
|
||||
`ui_recipe_overview.js` owns the overview; `ui_recipe_parameters.js` owns prompt roles,
|
||||
parameter editing, raw nodes, and preset saving; and `ui_recipe_model_matching.js`
|
||||
owns preview resolution plus explicit local matching. Inline persistence is centralized
|
||||
in `ui_recipe_metadata.js`, with pure ordering/value helpers in
|
||||
`ui_recipe_parameter_utils.js`. The catalog shell lives in `ui_recipe_catalog.js`,
|
||||
cards and card actions in `ui_recipe_cards.js`, save/edit dialogs in
|
||||
`ui_recipe_dialogs.js`, and shared cover helpers in `ui_recipe_media.js`.
|
||||
`ui_recipe_versions.js` owns version comparison and restore, while
|
||||
`ui_recipe_gallery.js` owns result rendering and opens `ui_gallery_detail.js`
|
||||
directly for image inspection. Shared detail DOM/copy primitives live in
|
||||
`ui_recipe_detail_dom.js`; the subviews return refresh/finish decisions through
|
||||
callbacks instead of redrawing one another.
|
||||
|
||||
Model names in compact recipe presentation use a basename or official model
|
||||
name, never a full filesystem path. Saved paths and hashes belong behind advanced
|
||||
information. A preview or exact path can locate current-machine presentation
|
||||
only after the reference is already understood; it cannot establish identity.
|
||||
|
||||
Import matching is a separate explicit recovery action. Unresolved references
|
||||
are sent to the hash/size/category resolver. A discovered candidate remains
|
||||
presentation-only until the user chooses Apply match; that action updates the
|
||||
authoritative workflow widget, model reference, and node-scoped Hash index
|
||||
through the full-recipe update path and archives the previous recipe. The
|
||||
author's saved filename or path is never match evidence. Model-reference
|
||||
`user_note` is recipe-scoped presentation metadata and never match evidence.
|
||||
|
||||
Recipe-owned model preview snapshots are bounded, content-addressed WebP files
|
||||
below `.assets/<recipe-stem>/`. They are at most 320 px or 96 KiB each, limited
|
||||
to 12 images and 1.25 MiB per save/update. Static local preview files are
|
||||
accepted; original videos are never packaged, though a bound local video may
|
||||
contribute a bounded first-frame thumbnail. Deleting a recipe removes only its
|
||||
contained assets after the recipe file is deleted. History may share an asset
|
||||
ID.
|
||||
|
||||
## Canvas actions
|
||||
|
||||
Recipe cards and details expose a scope-aware canvas action. A live graph with
|
||||
an active output node and no unconnected required inputs is saved as a complete
|
||||
recipe and opens as a new workflow canvas. A graph without an output node or
|
||||
with required connection boundaries is saved as a partial recipe and appends to
|
||||
the current canvas. Append clones saved nodes, assigns collision-free IDs,
|
||||
remaps links, places/selects the inserted content, treats groups as first-class
|
||||
items, remaps node-scoped model Hash records, and rolls back nodes/groups/Hash
|
||||
records together on failure. Legacy recipes without
|
||||
scope metadata are complete recipes because earlier releases only documented
|
||||
and captured complete workflows.
|
||||
|
||||
Structural editing is separate from composition. It may load a recipe into a new
|
||||
canvas after explicit confirmation and saves back through the full-recipe update
|
||||
path. It is not presented as an ambiguous “Open to canvas” recipe action.
|
||||
|
||||
All list/detail actions await the shared transaction before reporting success or
|
||||
restoring controls. A failure must leave the button usable and the host graph in
|
||||
its prior state.
|
||||
|
||||
## Result galleries
|
||||
|
||||
Recipe result discovery uses `sha256-node-types-v1`: the sorted node class
|
||||
composition and count. It intentionally ignores seeds, prompts, model values,
|
||||
and other parameters, so ordinary generation variations can match. This is
|
||||
separate from the structural integrity fingerprint.
|
||||
|
||||
Opening detail scans at most the newest 200 PNG files below the ComfyUI output
|
||||
directory and reads only bounded embedded `workflow` or API `prompt` metadata.
|
||||
There is no persistent output index or background polling. A chosen result may
|
||||
become the recipe-owned compressed cover; its original output path remains a
|
||||
local convenience reference rather than package content or identity evidence.
|
||||
|
||||
The main output Gallery performs its page-one scan when opened and exposes a
|
||||
manual Refresh action. It does not poll in the background and preserves scroll
|
||||
position across manual refresh.
|
||||
|
||||
## Parameter Notebooks
|
||||
|
||||
Parameter Notebooks are immutable generation-value snapshots owned by a recipe.
|
||||
Saving or updating a recipe creates a new snapshot. The Parameters tab uses a
|
||||
two-pane history browser: the left pane selects a notebook and the right pane
|
||||
prominently identifies the active name and timestamp. Selection requests carry a
|
||||
token so an older gallery response cannot overwrite a newer selection. Parameter
|
||||
result matching uses the separate `sha256-params-v1` signature.
|
||||
|
||||
“New parameter note” clones the selected recipe/snapshot into an editable draft
|
||||
and saves a new immutable snapshot. Rename uses the dedicated parameter endpoint
|
||||
and atomic persistence. Delete accepts only the validated notebook filename,
|
||||
invalidates in-flight requests, reloads the list, and selects the newest remaining
|
||||
note when necessary. The recipe's current baseline is not a deletable stored row.
|
||||
|
||||
Runtime-volatile values such as sampler seeds remain visible by field name but
|
||||
their values are excluded from summaries, copying, editing, matching, and apply.
|
||||
The serialized workflow retains the slots for compatibility.
|
||||
|
||||
“Read current and create” and “Apply to current workflow” share a skeleton
|
||||
preflight. Every saved node must match a local node by type and shape, preferring
|
||||
stable ID/title evidence; extra local nodes are allowed. Read-current refuses to
|
||||
create a draft if the skeleton is incomplete. Apply validates all widget slots
|
||||
before mutation, resolves serialized records to live nodes, invokes ComfyUI
|
||||
callbacks, and rolls values back if a callback fails.
|
||||
|
||||
The Node Assistant queries bounded notebook presets by selected node type. Its
|
||||
cache is invalidated by save/delete and bypassed by an explicit refresh. Applying
|
||||
a preset is a single-node transaction; it does not apply the full workflow.
|
||||
|
||||
## Prompt roles
|
||||
|
||||
Automatic prompt roles are conservative. `recipe_parser.js` traces backward
|
||||
from allowlisted official sampler/Guider conditioning inputs through allowlisted
|
||||
conditioning pass-through nodes to native `CLIPTextEncode` nodes. Unknown
|
||||
third-party nodes are opaque; titles, type-name guesses, prompt content, and a
|
||||
default-positive rule are not semantic evidence.
|
||||
|
||||
Saved role metadata joins to assistant records by string-safe node ID. Legacy
|
||||
recipes may recover a role only from an exact, unambiguous full prompt value in
|
||||
the saved positive/negative arrays. The same value in both arrays remains
|
||||
unknown.
|
||||
|
||||
Manual overrides live in `params.promptRoleOverrides`, keyed by string-safe node
|
||||
ID and guarded by saved node type. Values are `positive`, `negative`, `both`,
|
||||
`ignored`, or `unknown`. An override wins over automatic/legacy metadata; removing
|
||||
it restores the saved automatic result. Updates retain it only while ID and type
|
||||
still match.
|
||||
|
||||
## Recipe-to-model navigation
|
||||
|
||||
Opening a resolved local model from Recipe Overview keeps the outer browser open
|
||||
and temporarily replaces all main content surfaces with model detail. A compact
|
||||
return token retains the recipe payload, active tab, scroll position, and prior
|
||||
Workspace return state.
|
||||
|
||||
Model-detail Back, or reopening Workspace and selecting Workflow Recipes,
|
||||
consumes that token and reconstructs the exact recipe detail. Navigating directly
|
||||
to another main panel or closing Workspace abandons the transition and clears
|
||||
stale media/DOM/return state. Opening Workspace during the transition must not
|
||||
overwrite the earlier outer-panel return state.
|
||||
|
||||
Workflow Recipes and recipe-powered Assistant parameter presets remain beta
|
||||
surfaces until an explicit stability decision changes that status. Localized
|
||||
notices identify their data directories and distinguish single-node preset apply
|
||||
from full-skeleton Parameter Notebook apply.
|
||||
@@ -0,0 +1,56 @@
|
||||
# 更新引导维护契约
|
||||
|
||||
更新引导只解释明显变化,不承担功能开关、数据迁移或执行用户操作。
|
||||
修改前阅读根目录 [AGENTS.md](../../AGENTS.md)。
|
||||
|
||||
## 内容与实现分工
|
||||
|
||||
| 文件 | 责任 |
|
||||
| --- | --- |
|
||||
| `web/modules/update_guide_data.js` | 当前引导的稳定 ID、步骤顺序、图标和文案键;也提供配置校验 |
|
||||
| `web/modules/locales.js` | 每个步骤及按钮的中英文文案,均按纯文本展示 |
|
||||
| `web/modules/ui_update_guide.js` | 显示、翻页、关闭、焦点、版本记录;一般内容更新不要修改 |
|
||||
| `web/modules/sidebar_actions.js` | 六个底部按钮的短标签、完整名称和用途提示,不改变点击动作 |
|
||||
| `web/styles.css` | 引导布局和底部图标/文字切换的现有规则 |
|
||||
| `web/main.js` / `ui_sidebar.js` | 父窗口关闭清理,以及上方按钮和帮助页的手动入口 |
|
||||
|
||||
## 后续 AI 更新步骤
|
||||
|
||||
1. 先确认功能已实现及真实入口。不要在引导里承诺未上线、暂停或验证失败的功能。
|
||||
2. 在 `CURRENT_UPDATE_GUIDE.steps` 编写 1–5 个步骤,每项包含唯一 `id`、短 `icon`、`titleKey`、`bodyKey`。
|
||||
3. 在 `locales.js` 补齐这些键的中文和英文。标题说明变化,正文说明入口和用户该怎么用。禁止 HTML、可执行回调、请求及文件写入。
|
||||
4. **只有引导内容属于新一轮功能变化时才换 `id`**,例如 `2026-10-library-navigation`。修改错字、翻译、样式时保持 ID。不使用时间戳、随机数、Git 提交号或普通版本号自动生成 ID;换 ID 也不能自动弹窗。
|
||||
5. 配置校验会拒绝重复步骤、空步骤及缺失双语文案。配置错误只跳过引导,不应阻止插件打开;必须修正错误后再交付。
|
||||
6. 运行下方相关测试,再在浏览器验证打开插件不弹窗、上方入口、跳过后手动重开、帮助页重看、Esc 和长文案。
|
||||
7. 修改现有样式规则,不在文件末尾追加覆盖包。没有明确产品决策,不增加强制阅读、自动跳转、网络请求或额外追踪。
|
||||
|
||||
## 生命周期与记录
|
||||
|
||||
- 面板右上角常驻感叹号按钮(`#anomalous-update-notice-btn`),提供完整的用途提示和 accessible name。只有用户点击该按钮或帮助页入口才打开引导;初始化、打开插件、版本变化都不能触发弹窗。
|
||||
- 使用原生 `dialog` 的顶层显示和焦点约束,关闭时尽可能恢复原焦点。
|
||||
- 完成、跳过、×、Esc、点击空白关闭会记录已读,上方入口仍然保留;关闭父浏览器仅清理,不将未读引导标记为已读。入口不需要全局事件或延时隐藏。
|
||||
- 持久化键为 `anomalous_update_guide_seen:<id>`,值为 `1`。只保存本地已读状态,不发送到服务端。
|
||||
- 存储不可用时,当前页面会话仍记住关闭状态;刷新后仍必须手动点击才能显示。
|
||||
- 同一时刻只有一个引导。重复调用不叠加弹窗;所有关闭路径通过 `closeUpdateGuide` 释放资源。
|
||||
- 上方按钮和帮助页的“查看更新引导”均使用 `force: true`,允许重看已确认版本。
|
||||
- 引导不自动点击对应功能,也不修改画布、模型、素材、导入导出限制。
|
||||
|
||||
## 底部按钮约束
|
||||
|
||||
原按钮保留点击处理器、尺寸和图标。短标签是独立且 `aria-hidden` 的 `span`,
|
||||
不能占用已用于详细提示的 `::before`/`::after`。扫描状态替换 SVG 后重新配置标签。
|
||||
语言切换须更新六个标签、完整 accessible name 和用途提示。
|
||||
悬停与键盘焦点使用相同文字切换;无悬停设备直接显示短文字;减少动态效果偏好禁用位移动画。
|
||||
**防闪烁与稳定性铁律**:底栏按钮必须显式声明 `cursor: pointer !important; overflow: hidden;`,内部 SVG 与 Label 必须设置 `pointer-events: none !important;`。图标切换文本时**严禁使用 `display: none`**(必须使用 `opacity: 0` 保持物理文档流支撑),以避免浏览器 Hit-testing 丢失目标导致高频触发 `mouseleave` -> `mouseenter` 乒乓震荡闪烁。
|
||||
|
||||
## 验证
|
||||
|
||||
在插件根目录运行:
|
||||
|
||||
```powershell
|
||||
node --experimental-vm-modules tests/update_guide.mjs
|
||||
```
|
||||
|
||||
`update_guide.mjs` 加载真实模块,检查双语配置、版本记忆、重看、翻页、关闭清理、
|
||||
父窗口关闭、存储失败及扫描图标替换后的标签。它不能代替实际浏览器的布局与焦点验证。
|
||||
只有同时修改工坊生命周期或导入导出行为时,才追加对应测试;纯引导样式不运行无关测试。
|
||||
@@ -0,0 +1,200 @@
|
||||
# Anomalous Model Browser 全功能实测审计与问题复原报告
|
||||
**Audit & Functional Verification Report**
|
||||
|
||||
- **测试对象**:`Anomalous Model Browser` (异构模型浏览器 ComfyUI 插件)
|
||||
- **运行环境**:ComfyUI Portable (Windows x64) / 本地端口 `http://127.0.0.1:8188`
|
||||
- **当前 Git 分支**:`feature/recipe-model-binding`
|
||||
- **浏览器会话录屏存档**:`anomalous_deep_test_1789816916045.webp`
|
||||
|
||||
---
|
||||
|
||||
## 一、 测试操作过程完整复原 (Step-by-Step Reproduction)
|
||||
|
||||
本次实测通过浏览器子智能体(Browser Subagent)与本地真实运行的 ComfyUI 实例进行端到端(E2E)交互操作,测试全流程操作复原如下:
|
||||
|
||||
### 1. 插件唤起与主界面交互
|
||||
1. **浏览器进入 ComfyUI 画布**:
|
||||
- 页面初始加载默认工作流节点(包括 `Load Checkpoint`、`CLIP Text Encode`、`KSampler`、`Anima VAE` 等)。
|
||||
2. **唤起插件主界面**:
|
||||
- 点击 WebUI 界面悬浮触发球及左侧快捷立方体图标,`#anomalous-modal` 顺畅展开,居中层叠在画布上。
|
||||
- 验证右上角 `×` 关闭按钮与侧边栏 `☰` 折叠按钮,动画过渡自然,能顺畅收起并安全回到主画布。
|
||||
|
||||
### 2. 模型医生(Model Doctor)全局体检与修复测试
|
||||
1. **打开体检中心**:
|
||||
- 点击侧边栏听诊器图标(🩺),切换至 Model Doctor 面板。
|
||||
2. **体检扫描表现**:
|
||||
- 面板显示当前画布的节点模型状态:“健康 2 个 / 缺失 1 个”。
|
||||
- 正确检测到当前画布缺失模型:`Anima\qwen_image_vae.safetensors`。
|
||||
3. **交互操作测试**:
|
||||
- 点击右上角“刷新缓存”按钮,控制台正确触发 `app.refreshComboInNodes()` 和 `/anomalous/all_hashes` 刷新。
|
||||
- 查看缺失模型右侧操作按钮:包含了“深度哈希扫描”、“手动替换”、“查看哈希”以及“Civitai 搜索”链接。
|
||||
|
||||
### 3. 素材库(Material Library)与分类检索测试
|
||||
1. **打开素材库**:
|
||||
- 点击工具箱或侧边栏素材库图标,成功打开 Material Library 视图。
|
||||
2. **分类与搜索切换**:
|
||||
- 点击顶部筛选胶囊:“全部”、“工作流”、“提示词”等分类,卡片即时响应过滤。
|
||||
- 在搜索框输入关键词 `Kirazuri`,检索到对应的完整工作流素材卡片 `Kirazuri (Anima)_v4.0`。
|
||||
3. **卡片拖拽交互测试**:
|
||||
- 测试按住卡片尝试拖拽至画布空白处或节点上,排查拖拽反馈(发现重大逻辑断点,详见下文第二节)。
|
||||
|
||||
### 4. 模型扫描向导(Scan Wizard)与设置中心测试
|
||||
1. **打开扫描向导**:
|
||||
- 点击侧边栏雷达扫描图标,弹出 Scan Wizard 原生 Dialog 弹窗。
|
||||
- 查看各项扫描策略配置:包括完整哈希、Civitai 元数据提取等。
|
||||
- 测试取消弹窗:按 `Escape` 键或点击外部背景,发现原生 Dialog 没有绑定外部点击关闭逻辑,必须向下滚动找到底部的“取消”按钮才能退出遮罩层。
|
||||
2. **全局设置中心(Settings Hub)**:
|
||||
- 打开齿轮图标设置面板,测试在“紧凑模式 (Compact)”与“标准模式 (Standard)”之间来回切换,网格与卡片间距自适应良好。
|
||||
|
||||
---
|
||||
|
||||
## 二、 发现的核心缺陷与不合理之处深度剖析 (Critical Issues & Bugs)
|
||||
|
||||
通过“浏览器实际操作 + 源码 AST/逻辑级走查”,发现以下 **4 个影响核心体验或违反技术方案** 的问题:
|
||||
|
||||
---
|
||||
|
||||
### 🚨 缺陷 1(核心技术方案违背):本地缺失模型被误判为“⛔ 身份冲突”,导致修复链路完全中断
|
||||
|
||||
#### 1. 问题定位
|
||||
- **文件**:`api/model_resolution.py`(第 178-181 行)
|
||||
- **函数**:`_resolve_from_candidates`
|
||||
|
||||
#### 2. 代码问题根源
|
||||
在比对带有来源信息(Provenance,同时包含 `hash` 与 `size`)的模型时:
|
||||
```python
|
||||
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
|
||||
if hash_matches:
|
||||
return {"found": False, "identity_conflict": True}
|
||||
return {"found": False, "identity_conflict": True} # <--- 无论 hash_matches 是否为空,均返回 identity_conflict: True!
|
||||
```
|
||||
- 当用户导入一个网上下载的带有哈希的工作流,而本地**压根没有这个模型**时,`candidates` 或 `size_matches` 是空的。
|
||||
- 代码执行到末尾,**无条件返回了 `{"found": False, "identity_conflict": True}`**!
|
||||
|
||||
#### 3. 产生的不合理现象
|
||||
1. **误导用户**:在前端 `ui_doctor.js` 和 `ui_recipe_model_matching.js` 中,该模型会被标上鲜红的 `⛔ 身份冲突 (Identity Conflict)`,给用户一种“本地文件损坏或文件哈希冲突被系统拒绝”的假象。
|
||||
2. **阻断修复方案**:根据 `docs/architecture/model-resolution.md` 规范,只有当本地存在尺寸相同但哈希不同(或哈希相同但尺寸不同)的文件时,才算作真正的 `identity_conflict`。如果本地根本没有该模型,应该返回普通的 `{"found": False}`,以便触发正常的“未找到模型”提示、Civitai 自动检索建议或下载引导。
|
||||
|
||||
#### 4. 修复建议
|
||||
修改 `api/model_resolution.py`,只有在发现真正的冲突(如尺寸符合但哈希不匹配、或哈希符合但尺寸矛盾)时返回 `identity_conflict: True`;无任何匹配时应返回普通未找到:
|
||||
```python
|
||||
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
|
||||
if hash_matches:
|
||||
return {"found": False, "identity_conflict": True}
|
||||
return {"found": False}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🚨 缺陷 2(功能承诺未兑现 & 事件拦截冲突):素材库卡片“拖入画布载入工作流”无效且拖拽逻辑存在优先级漏洞
|
||||
|
||||
#### 1. 问题定位
|
||||
- **文件 A**:`web/modules/ui_material_cards.js`(第 90-96 行)
|
||||
- **文件 B**:`web/modules/material_drag.js`(第 55-84 行)
|
||||
|
||||
#### 2. 代码问题根源
|
||||
1. **提示与实现脱节(虚假功能)**:
|
||||
- 卡片上的 Tooltip 明确写着:`"按住可拖拽至画布节点注入参数,或拖至空白处载入工作流"`(`t('materialCardDragHint')`)。
|
||||
- 但是在 `ui_material_cards.js` 中绑定拖拽时:
|
||||
```javascript
|
||||
if (material.node_types?.length) {
|
||||
bindMaterialDrag(card, owner, {
|
||||
payload: () => ({ ...material, node_types: [...material.node_types], dragHint: t('materialDragParameters') || '拖拽素材参数至目标节点' }),
|
||||
accepts: (node, source) => source.node_types.includes(node.type),
|
||||
drop: (node, source, graph) => applyLibraryMaterial(owner, source, node, graph),
|
||||
// 致命缺失:根本没有传入 dropOnCanvas 回调!
|
||||
});
|
||||
}
|
||||
```
|
||||
**根本没有传入 `dropOnCanvas` 回调**!导致用户拖到空白画布释放时什么都没发生。
|
||||
- 此外,如果一个素材是**整图工作流素材**(`material.node_types` 为空),因 `if (material.node_types?.length)` 为假,导致**整张卡片完全没有被绑定拖拽能力**(`draggable = false`),用户根本拖不动!
|
||||
|
||||
2. **`material_drag.js` 中的命中优先级缺陷(Precedence Inversion)**:
|
||||
在 `material_drag.js` 的拖拽结束处理中:
|
||||
```javascript
|
||||
if (dropOnCanvas && overCanvas) {
|
||||
try { await dropOnCanvas(event, data, graph); }
|
||||
return;
|
||||
}
|
||||
if (!node || !accepts?.(node, data)) return;
|
||||
try { await drop(node, data, graph); }
|
||||
```
|
||||
- 只要定义了 `dropOnCanvas`,一旦鼠标落在画布区域(`overCanvas == true`),代码会**无条件先执行 `dropOnCanvas`**(载入整图工作流覆盖画布)!
|
||||
- 即便鼠标此时正精准悬停在某个兼容的节点上,也不会触发节点的参数注入!
|
||||
|
||||
#### 3. 修复建议
|
||||
1. 在 `material_drag.js` 中调整执行优先级:**先判断是否命中了兼容节点(Target Node),若命中了则优先执行节点注入;仅当未命中任何节点且落在画布空白区域时,才触发 `dropOnCanvas` 载入工作流**。
|
||||
2. 在 `ui_material_cards.js` 中,只要素材包含工作流(`material.has_workflow` 或 `material.data?.workflow`),就为其绑定 `dropOnCanvas: async (e, data, graph) => openMaterialWorkflow(owner, material.filename)`,并在 `material.node_types` 为空时也允许拖拽。
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 缺陷 3(交互体验问题):扫描向导(Scan Wizard)缺少遮罩点击关闭 & 纵向溢出
|
||||
|
||||
#### 1. 问题表现
|
||||
- 扫描向导使用原生的 HTML `<dialog>` 挂载并使用 `showModal()`。
|
||||
- 当弹窗内容较多(包含多项模型目录开关与说明)时,若屏幕纵向高度较小,底部“开始扫描”与“取消”按钮会被推到视口外部。
|
||||
- 用户尝试点击周围半透明深色遮罩(Backdrop)时,无法关闭弹窗;由于未在 `dialog` 上绑定 `click` 检测 `event.target === dialog` 的关闭逻辑,用户必须手动向下滚动寻找底部取消按钮。
|
||||
|
||||
#### 2. 优化建议
|
||||
- 在 `ui_scan_wizard.js` 中,为 Dialog 添加点击遮罩关闭逻辑:
|
||||
```javascript
|
||||
dialog.addEventListener('click', (e) => {
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
const isInDialog = (rect.top <= e.clientY && e.clientY <= rect.top + rect.height
|
||||
&& rect.left <= e.clientX && e.clientX <= rect.left + rect.width);
|
||||
if (!isInDialog) dialog.close();
|
||||
});
|
||||
```
|
||||
- 限制 `max-height: 85vh` 并对内部表单容器开启 `overflow-y: auto`,确保底部的操作按钮始终固定可见(Sticky Bottom)。
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 缺陷 4(同步与竞态隐患):模型单次精准扫描后前端下拉列表刷新时机
|
||||
|
||||
#### 1. 问题表现
|
||||
- 在针对单个模型触发精准扫描(`triggerDirectModelScan`)时,后端会计算 SHA-256 并生成 `.info` 文件。
|
||||
- 前端在通知扫描成功后,虽然调用了 `window.anomalous_reload_hashes()`,但在 ComfyUI 原生节点的下拉菜单选项(`widget.options.values`)重新拉取存在微弱的异步延迟,此时若立即在 Model Doctor 中点击“自动修复”,偶尔会触发 `optionsCacheStale`,需等待二次刷新才能消除红框。
|
||||
|
||||
#### 2. 优化建议
|
||||
- 在单模型扫描写入完成后,增加 `await fetch('/anomalous/clear_cache', { method: 'POST' });` 并等待 `await app.refreshComboInNodes()` 完成后再触发 Doctor 的状态比对。
|
||||
|
||||
---
|
||||
|
||||
## 三、 总结与排查结论
|
||||
|
||||
1. **整体功能架构**:
|
||||
插件整体架构非常先进且完整,Model Doctor、Material Library、Recipe 工作流、Scan Wizard 的多面板协调运作良好,UI 设计美观、动效细腻。
|
||||
2. **核心问题收敛**:
|
||||
- **核心 Bug 1**(`api/model_resolution.py` 误报 `identity_conflict`)是必须修复的高危逻辑漏洞,它直接导致工作流模型恢复方案失效;
|
||||
- **核心 Bug 2**(素材库拖拽无响应及画布/节点优先级颠倒)直接影响了用户对于“素材一拖即用”的核心预期。
|
||||
|
||||
---
|
||||
|
||||
## 四、 修复复查与验收记录 (Verification Sign-off)
|
||||
|
||||
- **验收时间**:2026-09-19 20:30
|
||||
- **修复 Commit**:`04b2eeff8f` (*fix(materials, doctor, scan): polymorphic material drag, third-party prompt nodes, model resolution, and scan wizard improvements*)
|
||||
- **复查录屏文件**:`anomalous_fix_verify_1789820782702.webp`
|
||||
|
||||
### 1. 自动化回归测试结果
|
||||
- **Python 后端单元测试**:运行 `unittest discover -s tests -p "test_*.py"`,**99 个测试全数通过 (OK)**。
|
||||
- 特别验证 `test_dynamic_hash_mismatch_is_rejected` 与空候选测试,确认无哈希匹配时不再误报 `identity_conflict`。
|
||||
- **前端契约与模块测试**:运行全部 32 个前端 `.mjs` 测试(涵盖 `studio_contracts.mjs`、`sidebar_feature_modules.mjs`、`material_feature_modules.mjs` 等),**32 项全部通过 (PASSED)**。
|
||||
|
||||
### 2. 浏览器端到端实测验证
|
||||
1. **模型修复与身份冲突 (Model Doctor)**:
|
||||
- 模型比对逻辑恢复正常,本地缺失模型返回 `found: False`,恢复正常下载与引导逻辑;
|
||||
- 唤起模型医生点击 `🔄 刷新缓存` 顺畅运行,控制台 0 报错。
|
||||
2. **素材库多态拖拽 (Polymorphic Material Drag)**:
|
||||
- 全图工作流素材:卡片 Tooltip 提示更新为“按住拖至空白画布载入完整工作流”,拖拽至空白画布成功加载完整图谱;
|
||||
- 提示词素材:支持拖拽至任意文本/提示词节点注入文本,或拖至空白画布自动创建对应 `CLIPTextEncode` 节点;
|
||||
- 节点参数素材:拖拽至目标节点优先注入参数,拖至空白画布新建对应节点;修复了过去由于优先级颠倒导致的画布与节点拦截冲突。
|
||||
3. **扫描向导 (Scan Wizard)**:
|
||||
- 弹窗底部操作按钮已设置固定粘性定位(Sticky Bottom),在任何屏幕高度下均不会被挤出视口;
|
||||
- 点击外部暗色遮罩 Backdrop 立即平滑关闭向导;
|
||||
- 按下键盘 `Escape` 键立即响应并关闭向导。
|
||||
4. **控制台与稳定性**:
|
||||
- 全流程操作无任何未捕获的 JavaScript 异常或 HTTP 异常状态码。
|
||||
|
||||
**验收结论**:本次修复完整、精准,所有指出的严重缺陷及交互体验问题均已彻底解决并闭环。
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 审计与测试报告索引 (Audits & Verification Reports)
|
||||
|
||||
本目录专门用于归档与整理 Anomalous Model Browser 插件在各开发阶段的端到端浏览器实测、质量审计、缺陷复原以及验收记录。
|
||||
|
||||
---
|
||||
|
||||
## 报告目录归档
|
||||
|
||||
| 日期 | 报告文件 | 测试类型 | 涉及模块 | 验收状态 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **2026-09-19** | [AUDIT_AND_BUG_REPORT.md](file:///e:/ComfyUI_windows_portable/ComfyUI/custom_nodes/Anomalous_Model_Browser/docs/audits/AUDIT_AND_BUG_REPORT.md) | E2E 浏览器全功能实测 & 缺陷深度排查 | Model Doctor / 素材库多态拖拽 / 扫描向导 / 模型恢复 | ✅ 已全数修复并验收闭环 |
|
||||
|
||||
---
|
||||
|
||||
## 维护规范
|
||||
1. 新增功能测试、跨版本兼容性体检或重大 Bug 排查报告时,请统一存放在本目录 (`docs/audits/`)。
|
||||
2. 保持插件根目录整洁,避免在根目录直接堆放临时或专项测试文档。
|
||||
@@ -0,0 +1,117 @@
|
||||
# Current Architecture Decisions
|
||||
|
||||
This index records concise reasons for enduring product and architecture
|
||||
boundaries. It is not a changelog. When a decision becomes obsolete, update or
|
||||
replace the record and rely on Git history for the former implementation.
|
||||
|
||||
## AD-001 — One command, exclusive browser entries
|
||||
|
||||
The floating trigger, action-bar button, and Extensions command share one
|
||||
`openBrowser()` command and one browser instance. Presentations are mutually
|
||||
exclusive, while the Extensions command always remains as a recovery route.
|
||||
Native ComfyUI command/keybinding infrastructure owns shortcut recording and
|
||||
modal guards.
|
||||
|
||||
## AD-002 — Recipes are templates; notebooks are immutable instances
|
||||
|
||||
A Workflow Recipe is the authoritative workflow template. Parameter Notebooks
|
||||
are immutable generation-value snapshots attached to a recipe and browsed inside
|
||||
its Parameters tab. The Node Assistant may reuse those snapshots for a selected
|
||||
same-type node, but this does not replace full-skeleton recipe application.
|
||||
|
||||
## AD-003 — Recipe canvas actions follow graph scope
|
||||
|
||||
Recipe cards and detail views append partial recipes because their open required
|
||||
connections make them composition fragments. Complete, independently runnable
|
||||
recipes load through ComfyUI's workflow path and therefore open as a new canvas
|
||||
in the current frontend. Structural editing remains a separate, explicitly
|
||||
confirmed workflow.
|
||||
|
||||
## AD-004 — Recipe save hides implementation policy
|
||||
|
||||
Save collects identity, notes, tags, and cover/source image. Presentation pins
|
||||
and snapshot policy are implementation choices, not per-save questions. Legacy
|
||||
pin data is preserved without exposing a control; new recipes default to none.
|
||||
|
||||
## AD-005 — Model identity excludes names and previews
|
||||
|
||||
Model Doctor automatically proves identity through provenance hash and required
|
||||
category; byte size may disambiguate that hash. A unique size-only match is a
|
||||
manual candidate, never proof or an automatic redirect. Hash/size conflicts are
|
||||
rejected. Local paths, filenames, display names, previews, and similarity are
|
||||
presentation or post-resolution routing data. This boundary prevents silent
|
||||
substitution after local rename or across categories.
|
||||
|
||||
## AD-006 — Foundation components are hash-only recovery categories
|
||||
|
||||
VAE, VAE Approx, CLIP/Text Encoder, and CLIP Vision files can commonly share
|
||||
sizes or ambiguous names. Automated redirection therefore requires one exact
|
||||
in-category hash match. A size-only candidate may still be shown for explicit,
|
||||
one-time confirmation on the current node. Existing native combo values remain
|
||||
usable with a visible identity-change warning when provenance differs. This does
|
||||
not restrict scan coverage: scanning may calculate hashes for any active
|
||||
registered folder, including categories whose Civitai metadata coverage is weak.
|
||||
|
||||
## AD-007 — Recipe origins and local availability are separate
|
||||
|
||||
Official origin metadata can be refreshed or edited independently of saved
|
||||
identity. Exact-path availability checks and previews describe the current
|
||||
machine; they do not rewrite imported provenance or activate a local match.
|
||||
|
||||
## AD-008 — Prompt roles prefer unknown over a guess
|
||||
|
||||
Automatic prompt role tracing follows only allowlisted native prompt,
|
||||
conditioning, and consumer nodes. Unknown third-party nodes are opaque. Legacy
|
||||
full-value recovery must be exact and unambiguous, and users may store explicit
|
||||
recipe-scoped overrides.
|
||||
|
||||
## AD-009 — Recipe-to-model navigation is recoverable and exclusive
|
||||
|
||||
Model detail temporarily replaces all main content surfaces while retaining a
|
||||
small recipe return token. Back reconstructs recipe detail; unrelated navigation
|
||||
abandons the token and cleans stale state. The transition never closes the outer
|
||||
browser or exposes model detail beside Node Assistant.
|
||||
|
||||
## AD-010 — Parameter selection must identify both panes
|
||||
|
||||
The active Parameter Notebook is visible in the list and in a right-pane banner
|
||||
with name and timestamp. Requests are selection-tokened so stale gallery results
|
||||
cannot overwrite the new notebook. Sidebar input/button rows must remain usable
|
||||
in the narrow docked layout.
|
||||
|
||||
## AD-011 — Output discovery remains bounded and user-refreshed
|
||||
|
||||
Recipe matching inspects at most the newest bounded set of PNG metadata and has
|
||||
no persistent index. The main Gallery scans on open and refreshes manually. This
|
||||
avoids background polling and hidden indexing cost while retaining predictable
|
||||
freshness.
|
||||
|
||||
## AD-012 — Licensing and branding are separate
|
||||
|
||||
The standard MIT `LICENSE` applies to project-owned code and documentation.
|
||||
`TRADEMARKS.md` separately governs the Anomalous Model Browser name and official
|
||||
branding. Project-specific restrictions are not appended to the MIT license.
|
||||
|
||||
## AD-013 — Beta guidance is scoped to experimental workflow features
|
||||
|
||||
Workflow Recipes and recipe-powered Assistant presets carry localized backup and
|
||||
data-safety guidance until an explicit stability decision removes it. Stable
|
||||
Node Assistant model replacement and LoRA insertion are outside that warning.
|
||||
Notices distinguish single-node preset application from full-skeleton Parameter
|
||||
Notebook application.
|
||||
|
||||
## AD-014 — Architecture documentation is state, not history
|
||||
|
||||
Architecture documents change only when an owner, data flow, interface contract,
|
||||
persistence format, security boundary, or critical invariant changes. Ordinary
|
||||
fixes do not append “unchanged” entries. Git is the implementation history;
|
||||
`CHANGELOG.md` is user-facing release history; `.agents/logs/ai_lessons.md` holds
|
||||
only durable recurring traps.
|
||||
|
||||
## AD-015 — Recipe model mutations update all identity representations
|
||||
|
||||
The authoritative workflow widget, structured model reference, and node-scoped
|
||||
Model Doctor Hash index form one mutation boundary. Local matching updates all
|
||||
three through the archived full-recipe path. Partial append remaps Hash records
|
||||
with node IDs and rolls them back with the inserted graph. Personal model notes
|
||||
are recipe-scoped presentation data and may be excluded from package export.
|
||||
@@ -0,0 +1,248 @@
|
||||
# 代码结构拆分计划(交给 GPT-5.6-sol)
|
||||
|
||||
日期:2026-09-17。代码基线:`29b14d2c7`。**状态:阶段 0–7 已完成;自动化验收通过,真实 ComfyUI 宿主视觉/交互验收仍需在运行中的宿主完成。**
|
||||
|
||||
用户当前优先级是整理代码结构,暂停其他 UI 美化与功能扩展。本文保留原实施任务单,并在末尾记录实际完成结果;当前架构以专题文档和代码为准。
|
||||
|
||||
## 1. 要达到的结果
|
||||
|
||||
将大文件按实际职责拆开,做到修改一个页面或一项行为时,不必理解整个浏览器。保留现有界面、操作、接口和用户数据。每完成一个可验证的小阶段就本地提交,继续下一阶段,不必逐项询问用户。
|
||||
|
||||
不是把大文件切成 `part1/part2`,也不是迁移到新框架。不得为拆分引入打包系统、事件总线、通用服务容器或新依赖。
|
||||
|
||||
阅读顺序:[AGENTS.md](../../AGENTS.md) → [架构入口](../../ARCHITECTURE.md) → 当前阶段对应的专题。不要每轮通读所有文档。本文的文件名是拟定归属,先搜索是否已有等价模块;存在时优先复用。
|
||||
|
||||
## 2. 当前规模与真正的拆分点
|
||||
|
||||
以下是含空行的物理行数,仅用于定位;执行时按函数和调用方搜索,不按固定行号切割。
|
||||
|
||||
| 文件 | 行数 | 主要问题 |
|
||||
| --- | ---: | --- |
|
||||
| `web/styles.css` | 14,620 | 多个页面、主题和响应式规则混在一起,层叠顺序容易被破坏 |
|
||||
| `web/modules/ui_recipe_detail.js` | 3,348 | 总览、模型匹配、参数编辑、版本、图库集中在一个模块 |
|
||||
| `web/modules/ui_sidebar.js` | 2,963 | `createDOM()` 约 2,250 行,包含扫描向导、设置、工具箱、导航和窗口交互 |
|
||||
| `web/modules/ui_doctor.js` | 2,186 | 模型诊断、节点助手、替换图库、参数方案交叉放置 |
|
||||
| `web/modules/ui_detail.js` | 1,938 | 模型详情、编辑弹窗、高级选择器合并 |
|
||||
| `api/recipes.py` | 1,514 | 工作流规范化、身份信息、图片、文件存储、历史和 HTTP 路由混合 |
|
||||
| `web/modules/ui_gallery_detail.js` | 1,453 | 图片工作台生命周期、媒体交互、参数检查与保存操作集中 |
|
||||
| `web/main.js` / `api/models.py` | 各 1,413 | 前者注册、设置、实例、浮动入口耦合;后者目录、媒体、修改、匹配路由耦合 |
|
||||
| `web/modules/ui_recipes.js` | 1,297 | 列表筛选、卡片、保存/编辑对话框与保存流程混合 |
|
||||
| `api/materials.py` | 1,294 | 素材构建、提示词规则、资源存储、查询、路由混合 |
|
||||
| `web/modules/ui_materials.js` | 1,186 | 目录、详情、节点选择监听、应用操作混合 |
|
||||
| `web/modules/ui_notebooks.js` | 1,042 | 工作台返回、笔记目录、编辑器和节点创建混合 |
|
||||
|
||||
`locales.js` 为 2,373 行,主体是字典,不优先拆。已有提示词工坊、来源中控等模块虽然仍较大,已有明确边界,放到后续复核,避免第一轮同时动所有系统。
|
||||
|
||||
**已确认的耦合:**
|
||||
|
||||
- `main.js` 通过 `AnomalousBrowser.prototype` 接入各 UI 模块。首轮保留方法名、参数和 `this` 绑定,只调整导入归属;不要同时更换整个实例模式。
|
||||
- `ui_sidebar.js` 的扫描状态、语言刷新、按钮和导航大量依赖 `createDOM()` 闭包。移动函数前必须列出它读取、修改的外部变量。
|
||||
- `api/materials.py` 从 `recipes.py` 导入了 12 个名称;`parameters.py` 也导入其中的参数签名、读取与图库辅助函数。后端不能把每份文件单独移动后再补循环导入。
|
||||
- `ui_gallery_detail.js` 有模块级 `wb` 和元数据缓存。它们不能在多个新文件各复制一份。
|
||||
- `api/__init__.py` 目前用星号导入路由。最后接线时应改成明确的模块引用,防止移动后隐式导出丢失或名称覆盖。
|
||||
|
||||
## 3. 拆分时保持的边界
|
||||
|
||||
依赖方向采用现有结构:**宿主接线 → 页面协调与视图 → 领域逻辑/存储与已有公共工具**。纯逻辑不能反向导入页面,子视图不能导入 `main.js` 获取实例。
|
||||
|
||||
- 每项状态只保留一份。已有跨页返回状态先留在当前 owner;局部 DOM、请求和清理归所属视图。迁移状态和移动代码分开提交,避免难以区分故障来源。
|
||||
- 为实际功能传入必要容器、数据和回调。若拆分需要几十个闭包参数,说明边界选错,应把相关状态和行为一起归入该功能模块。
|
||||
- 模块接入时直接修改所有内部调用方。只有确有外部调用或兼容需求的入口才保留薄转接,不建立永久的重复实现。
|
||||
- 同一阶段保持路由 URL、请求/响应结构、文件路径、字段、存储锁、撤销行为、节点连接与参数写入语义不变。不迁移用户数据。
|
||||
- 复用 `ui_lifecycle.js`、`ui_dom.js`、`recipe_actions.js`、`recipe_parser.js`、`recipe_identity.js`、`node_material_actions.js` 等现有能力,不再复制一套。
|
||||
- 普通逻辑模块通常以 200–600 行为参考。确有多个职责才继续拆,不能靠压缩代码、删注释或空壳包装达标。语言表和样式表不套这个阈值。
|
||||
- 文件改放子目录时重新计算相对导入,特别是 ComfyUI 的 `scripts/app.js` 和图片 URL。导入无副作用的模块不应注册第二个扩展、启动扫描或立即创建页面。
|
||||
|
||||
## 4. 分阶段执行
|
||||
|
||||
每个阶段允许拆成几个提交;一个提交完成一个可验证的职责迁移。不要一次把以下所有文件建好再尝试运行。
|
||||
|
||||
### 阶段 0:建立本轮基线
|
||||
|
||||
1. 查看状态和最新差异,记录起始提交;保留他人的改动。
|
||||
2. 为即将移动的职责记录:入口/调用方、读写状态、全局监听、请求、DOM/CSS 依赖、关闭路径。只写本阶段,不建立全仓超大清单。
|
||||
3. 先运行本阶段已有的相关测试,记录已有失败。需要补测试时优先覆盖真实行为,而不是源码中是否存在某个字符串。
|
||||
4. 记录相关页面的当前视觉和交互。后续比较必须使用同一主题、窗口尺寸和数据;不拿不同时期截图作基线。
|
||||
|
||||
### 阶段 1:先从侧栏移走独立功能
|
||||
|
||||
目标文件:`ui_sidebar.js`。第一刀选扫描向导,不先重写导航。
|
||||
|
||||
| 拟建模块 | 迁移范围与接口 |
|
||||
| --- | --- |
|
||||
| `ui_scan_wizard.js` | `createWizardModal()` 及其专属构建/扫描控制;接收全局或指定模型范围与完成回调,返回打开、关闭与语言刷新所需接口 |
|
||||
| `ui_folder_manager.js` | `openFolderManager()` 与该对话框专属请求、DOM、关闭逻辑;原来的目录浏览 `renderSidebar/loadFolders` 暂时保留 |
|
||||
| `ui_help.js` | `showHelp()` 及内容构建,仅在打开时创建所需内容 |
|
||||
|
||||
扫描进度继续使用 `scan_progress.js`,不能新增另一份“是否正在扫描”。若该状态也被全局按钮使用,明确由扫描功能拥有,按钮只读取/订阅实际状态。关闭向导是否取消扫描必须保持当前语义,不能把 UI 关闭误当成后台任务取消。
|
||||
|
||||
验收:扫描向导打开/关闭、单模型与全局入口、失败反馈、文件夹管理取消与保存刷新、帮助关闭。涉及扫描或写文件的验证优先使用隔离数据与桩服务,不自动扫描用户整个模型库。
|
||||
|
||||
### 阶段 2:收拢外壳和入口
|
||||
|
||||
在阶段 1 稳定后,从 `createDOM()` 提取:
|
||||
|
||||
- `ui_settings_hub.js`:设置中心与模型显示设置。接口覆盖 open/close/refreshLanguage,保留原来的设置来源、键名和生效时机。
|
||||
- `ui_toolbox.js`:工具箱内容、开关和底部按钮装配;复用 `tool_registry.js`、`shortcut_layout.js`、`sidebar_actions.js`、`ui_shortcut_organizer.js`。按**当前代码**保持固定栏与工具过滤规则,不顺便实施旧计划中的另一种交互。
|
||||
- `ui_browser_navigation.js`:主面板切换与返回协调。先收拢现有 `hideAllPanels/closeWorkspace` 相关调用,不引入新的路由系统;笔记模块最终不再独占其他工作台的公共关闭逻辑。
|
||||
- 窗口拖动、缩放、停靠先保留在外壳;仍形成独立大段时再提取 `browser_window.js`,复用生命周期工具,不改变尺寸偏好。
|
||||
|
||||
`createDOM()` 最终只创建骨架、装配功能、连接明确回调。初始化完所有被引用的控件后再绑定/刷新语言,避免迁移后出现 TDZ 或读取尚未初始化的按钮。
|
||||
|
||||
随后整理 `main.js`:浏览器类与方法接线放到 `browser.js`,浮动入口放到 `browser_entry.js`,设置描述与语言/主题应用放到 `interface_settings.js`。扩展注册、实例创建顺序和宿主钩子仍由 `main.js` 统一协调。设置模块通过参数/回调工作,不能反向导入 main;保留被其他模块调用的现有全局入口,先核实调用者再删除。
|
||||
|
||||
验收:首次打开、关闭重开、各主页面切换、配方→模型详情→配方、语言切换、设置保存、工具箱提示、停靠、窗口拖动与调整尺寸。确认只有一个浏览器实例和一份全局监听。
|
||||
|
||||
### 阶段 3:拆配方详情,再拆目录
|
||||
|
||||
`ui_recipe_detail.js` 留下 `showRecipeDetail()`、活动页签、一次详情会话的状态与生命周期协调。按下面顺序逐块移动:
|
||||
|
||||
1. `ui_recipe_versions.js`:`renderVersions/renderDiffPanel`。继续用 `recipe_diff.js`;恢复版本后的刷新由明确回调交回详情协调器。
|
||||
2. `ui_recipe_gallery.js`:`renderRecipeGallery/showGalleryComparison` 及专属资源处理;图片详情打开通过回调或直接依赖现有图片工作台入口,不能绕素材目录模块中转。
|
||||
3. `recipe_model_matching.js` + `ui_recipe_models.js`:前者负责匹配操作与结果,后者负责组成卡片、来源编辑和本地匹配操作界面;复用身份规则,不能从名称/预览图推断同一模型。
|
||||
4. `recipe_parameter_data.js` + `ui_recipe_parameters.js`:先移出 `parameterNodeOrder/topologicalSortNodes/parseEditorValue/paramsWithPromptRole` 等无 DOM 逻辑,再移参数方案 UI。较大的原始节点检查与编辑器可分别放 `ui_recipe_node_inspector.js`、`ui_recipe_parameter_editor.js`。
|
||||
5. `ui_recipe_overview.js`:总览与元数据编辑,参数正文读取继续遵循真实工作流,不改用列表摘要。
|
||||
|
||||
不要把所有小函数扔进 `recipe_detail_utils.js`。格式化工具仅在确有多个调用者时共享;请求成功后的刷新由协调器处理,不让子页互相直接调用重绘。
|
||||
|
||||
接着将 `ui_recipes.js` 的 `showRecipeSaveDialog/showRecipeEditDialog` 放到 `ui_recipe_dialogs.js`,卡片渲染放 `ui_recipe_cards.js`;列表查询、筛选、分页与页面状态留在目录模块。`handleSaveRecipe` 继续协调实际捕获和保存,不能在新对话框中复制该流程。
|
||||
|
||||
验收:配方筛选与详情、长提示词、原始参数、参数方案保存再读回、模型详情往返、历史比较/恢复、图库打开/关闭。配方包导入导出保持当前关闭状态,移除的入口不恢复。
|
||||
|
||||
### 阶段 4:拆其余混合页面
|
||||
|
||||
逐行执行下表,每行独立提交与验证,禁止把全部页面同时改成半成品。
|
||||
|
||||
| 当前文件 | 拟定归属 | 重点验证 |
|
||||
| --- | --- | --- |
|
||||
| `ui_doctor.js` | 保留诊断面板和全局扫描;节点助手到 `ui_node_assistant.js`;`_openGalleryReplacer` 到 `ui_node_model_picker.js`;参数方案卡片与预览到 `ui_node_presets.js` | 哈希依据不变;替换正确节点;取消无写入;助手历史可用 |
|
||||
| `ui_detail.js` | 保留 `showDetail`;`showEditModal` 到 `ui_model_editor.js`;`_openAdvancedModelSelector` 到 `ui_model_selector.js` | 详情往返、编辑保存、选择器取消、原节点与路径不串 |
|
||||
| `ui_gallery_detail.js` | 保留工作台打开/关闭与唯一 `wb`;舞台缩放/胶片栏到 `ui_image_stage.js`;参数、提示词、模型与节点检查到 `ui_image_inspector.js` | 快速换图无旧结果覆盖;关闭释放媒体与请求;选中块保存准确 |
|
||||
| `ui_materials.js` | 保留目录与分页;详情到 `ui_material_detail.js`;卡片到 `ui_material_cards.js`;应用和选择监听尽量归入已有 `ui_material_application.js` | 素材拖放仍整体替换;节点身份复核;详情读正文;翻页与筛选不丢 |
|
||||
| `ui_notebooks.js` | 保留笔记目录与保存协调;编辑器到 `ui_notebook_editor.js`;画布创建到 `notebook_canvas.js`;公共返回已归阶段 2 | 保存队列和重开不丢内容;正负提示词隔离;节点创建与连接不变 |
|
||||
|
||||
图片元数据缓存可以留在协调模块,缓存上限和失效规则保持不变。需要跨模块使用时暴露窄访问接口,不复制缓存,不把 `wb` 作为可随意修改的全局对象导出。
|
||||
|
||||
### 阶段 5:样式独立拆分,不同时重新设计
|
||||
|
||||
保留 `web/styles.css` 作为外部入口。先核对 `main.js` 的 CSS 加载逻辑,再把样式拆到 `web/styles/` 下的基础控件、外壳、模型、图库、配方、素材、提示词、工具与主题等文件。
|
||||
|
||||
**首先保证原始层叠顺序。** 不能按关键词把分散的同名选择器直接合并搬走。先记录规则顺序与主题/媒体条件;需要时按连续职责块分两步迁移,稳定后再清理重复声明。混合多个领域的选择器先归公共部分,未经确认不拆成几个不同位置的规则。
|
||||
|
||||
低风险方案是入口顶部有序 `@import`。若使用多 link,也必须只有一份确定顺序的清单,并保留防重复加载。二选一,不能双重加载。不要引入 CSS cascade layers,因为它会改变已有优先级。
|
||||
|
||||
迁移时检查相对 `url(...)` 资源路径、`@keyframes`、主题限定、媒体查询和插件作用域;子样式的缓存刷新也要验证,不能只刷新入口版本。检查网络中是否缺文件、串行请求造成明显闪烁。没有测试依据,不顺便做按页面动态加载。
|
||||
|
||||
验收:同条件截图/关键计算样式对比,普通主题与深海血族、普通窗口与停靠、浮层层级、文件夹隐藏/恢复、提示定位、拖放与键盘焦点。此阶段不换配色、间距、动画或 DOM 结构。
|
||||
|
||||
### 阶段 6:先拆后端共享领域,再移动路由
|
||||
|
||||
先读 [后端专题](../architecture/backend.md)、[配方专题](../architecture/recipes.md)、[素材专题](../architecture/material-library.md)。
|
||||
|
||||
1. 从 `recipes.py` 提取 `workflow_schema.py`:工作流校验、指纹、参数签名、易变字段规则等纯计算。确属配方专有的规范化放 `recipe_schema.py`;不要把文件路径和 HTTP request 带入纯逻辑。
|
||||
2. 提取 `recipe_store.py`:目录、读取、写入与历史操作;提取 `recipe_images.py`:来源图片验证、读取与封面处理。路径授权仍调用现有安全工具,I/O 仍放到原来的工作线程边界。
|
||||
3. 同步改 `materials.py`、`parameters.py` 和 `recipe_packages.py` 的调用。后者目前是 `from . import recipes as recipe_store`,这个别名指向旧路由模块,不是拟建的存储模块。逐项检查其 `recipe_store.xxx` 访问,不能只搜索导入行或直接把别名换成新文件。
|
||||
4. 路由模块依赖领域与存储模块,领域模块不得反向导入路由。锁和缓存只保留在实际拥有者中,同一类写入不能因拆文件变成两把独立锁。
|
||||
5. `api/recipes.py` 最终保留 HTTP 参数解析、错误/状态码映射、调用和响应。不要为了“薄路由”把整个旧文件原封不动搬进另一个 1,500 行的 service。
|
||||
|
||||
随后拆 `api/models.py`:目录查询/列表、身份解析、元数据修改、媒体与封面分别归入 `model_catalog.py`、`model_resolution.py`、`model_metadata.py`、`model_media.py`。先按现有端点边界移动,公共路径与类别规则继续复用 `utils.py` 和根目录的 `model_policies.py/model_identity.py`。保留跨目录回退和受保护类别规则。
|
||||
|
||||
最后拆 `api/materials.py`:`material_schema.py` 负责构建与提示词规范化,`material_store.py` 负责记录及资源生命周期,路由模块负责请求响应和有界查询。若存储模块仍大,再将图片资产操作独立成 `material_assets.py`,不预先造空模块。
|
||||
|
||||
`api/__init__.py` 改成明确的模块引用和注册,例如 `from . import models`、`models.api_get_models`;比较拆分前后的 **HTTP method + path** 清单,避免少注册、多注册或重复注册。用户设置和导入导出 gate 保持不变。
|
||||
|
||||
验收全部使用临时目录与隔离配置:保存/读取往返、非法路径拒绝、失败保留旧文件、历史恢复、素材分页过滤、修改封面/元数据、禁用导入导出的 503。不能通过删除或覆盖用户真实数据测试。
|
||||
|
||||
### 阶段 7:收尾与剩余热点复核
|
||||
|
||||
- 再统计大文件,按职责复核 `ui_prompt_workbench.js`、`ui_prompt_source_deck.js`、`ui_model_sources.js`、`material_inspector.js` 和 `api/utils.py`。有独立职责才继续拆,并记录边界;不能仅因超过阈值机械拆分。
|
||||
- 复核导入环、无人调用的旧导出、遗留重复实现和 `EXTRACTED` 占位。不要用任意 optional chaining 或空 catch 掩盖断掉的接线。
|
||||
- 更新实际发生变化的架构专题与入口所有者列表。此时删除过时说明,不能把本文的拟定方案提前复制成已实现架构。
|
||||
- 本计划逐阶段补充提交号、验证和剩余事项;发生中断时,下一轮从这里继续,不重新扫描整个仓库。
|
||||
|
||||
## 5. 验证与效率要求
|
||||
|
||||
每阶段先做 JS 语法/模块链接或 Python 编译/导入检查,再跑相关行为测试。涉及界面和宿主行为时还需真实 ComfyUI 验证,语法通过不能代替点击路径。移动模块后更新测试入口,尽量加载实际模块,不把待测行为本身 mock 成成功。
|
||||
|
||||
当前存在的定向检查(从插件根目录运行)。下表每个 JS 文件分别用 `node --experimental-vm-modules <文件路径>` 执行;此处参数表示替换成表内实际路径,不是一次执行整表:
|
||||
|
||||
| 影响范围 | 现有检查 |
|
||||
| --- | --- |
|
||||
| 外壳/快捷栏 | `tests/shortcut_layout.mjs`、`tests/shortcut_organizer.mjs`、`tests/update_guide.mjs` |
|
||||
| 素材与节点应用 | `tests/material_workspace_flow.mjs`、`tests/studio_contracts.mjs` |
|
||||
| 笔记 | `tests/notebook_render_test.mjs`、`tests/notebook_save_roundtrip.mjs` |
|
||||
| 提示词工坊/读取 | `tests/prompt_ui_lifecycle.mjs`、`tests/prompt_material_source.mjs`、`tests/prompt_library_sync.mjs` |
|
||||
| 模型来源 | `tests/model_sources_hub.mjs` |
|
||||
| 导出 gate | `tests/export_availability.mjs`;Python 用便携环境执行 `python -B tests/test_export_availability.py` |
|
||||
|
||||
表中测试存在不等于本轮已经通过。配方详情、模型编辑、扫描向导、后端存储等未被这些测试充分覆盖的路径,需要有针对性地补行为验证,不能拿无关测试通过数充当覆盖。
|
||||
|
||||
新增测试先核对 `.gitignore`,必须明确哪些随仓库交付。每次只跑受影响的集合;阶段完成后不重复跑同一组,除非又改了代码或发现新问题。
|
||||
|
||||
完成标准:原调用方都接入新归属;原文件没有第二份实现;关键行为验证通过;入口和返回仍可用;无新增持续轮询、全量数据加载或监听泄漏。行数下降本身不是验收标准。
|
||||
|
||||
## 6. 交给 GPT-5.6-sol 的启动指令
|
||||
|
||||
> 请按 docs/plans/code-structure-refactor.md 整理 Anomalous_Model_Browser 的代码结构。先读 AGENTS.md 和相关架构专题,确认最新差异,从阶段 0、阶段 1 开始,然后按依赖顺序继续。保留现有外观、功能、数据和接口,不顺手美化,不恢复已关闭或移除的功能。
|
||||
>
|
||||
> 每次完成一个职责迁移,检查调用方、状态归属、闭包依赖和关闭路径,运行相关验证后本地提交,再继续。常规实现选择自行处理;遇到已有故障先区分基线与回归,不能隐藏或伪造成功。不要把所有文件一次拆完才验证。
|
||||
>
|
||||
> 每阶段在计划中记录实际模块归属、提交号、验证结果与未完成项。需要中断时留下可接续状态;不推送远端。最终说明职责如何变清楚、哪些行为已验证,以及还有哪些真实限制。
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 阶段 0(完成):以 `c39701773` 为本轮起点,开工时工作区干净。基线通过
|
||||
`shortcut_layout.mjs`、`shortcut_organizer.mjs`、`update_guide.mjs`;未建立真实
|
||||
ComfyUI 点击截图基线,因此后续不能把 DOM 模拟检查称为宿主视觉验收。
|
||||
- 阶段 1(完成,`2a53e3edf`):扫描向导归 `ui_scan_wizard.js`,文件夹管理归
|
||||
`ui_folder_manager.js`,帮助弹窗归 `ui_help.js`。单模型卡片与全局扫描入口共用
|
||||
`openScanWizard()`;新增行为检查覆盖帮助关闭、文件夹取消/保存刷新、单模型扫描请求
|
||||
与向导关闭不取消后台扫描。
|
||||
- 阶段 2(代码拆分完成,`1e42033ac`、`67d5be6277`、`a984ee4a31`):设置中心与模型卡显示设置归
|
||||
`ui_settings_hub.js`,工具目录、固定快捷栏和工具分派归 `ui_toolbox.js`,公共面板
|
||||
隐藏/配方详情清理归 `ui_browser_navigation.js`。`ui_sidebar.js` 已从约 2,963 行降至
|
||||
约 780 行,`createDOM()` 现在主要保留外壳、顶部导航、窗口交互和子模块装配。
|
||||
浏览器类和方法装配归 `browser.js`,浮动/顶栏/菜单入口及唯一实例归
|
||||
`browser_entry.js`,语言和主题设置归 `interface_settings.js`;`main.js` 现只协调
|
||||
扩展注册与宿主钩子。笔记、配方和素材共用的工作区返回协调也已迁入
|
||||
`ui_browser_navigation.js`。新增模块边界测试覆盖注册描述、入口设置、语言、主题与
|
||||
工作区恢复;真实 ComfyUI 拖动、停靠与窗口缩放仍待宿主验收。
|
||||
- 阶段 3(代码拆分完成,`d8691d881`、`413b876dfe`、`56774a2d99`、`881570e887`、`e5f3301971`):配方版本历史/差异/恢复归
|
||||
`ui_recipe_versions.js`,结果图库与图片工作台跳转归 `ui_recipe_gallery.js`,跨子页
|
||||
共用的 DOM、长值展示和复制控件归 `ui_recipe_detail_dom.js`。删除了无人调用的旧图库
|
||||
对比实现;图库不再借道素材目录模块打开图片工作台。目录筛选归
|
||||
`ui_recipe_catalog.js`,卡片与拖拽归 `ui_recipe_cards.js`,保存/编辑对话框归
|
||||
`ui_recipe_dialogs.js`,封面媒体归 `ui_recipe_media.js`;模型预览、匹配与显式替换归
|
||||
`ui_recipe_model_matching.js`,总览归 `ui_recipe_overview.js`,提示词角色、参数编辑、原始节点与
|
||||
参数预设归 `ui_recipe_parameters.js`,内联持久化归 `ui_recipe_metadata.js`。协调器
|
||||
`ui_recipe_detail.js` 从约 2,983 行降至约 760 行,`ui_recipes.js` 从约 1,297 行降至约
|
||||
690 行。新增检查覆盖版本恢复、图库交接、目录卡片隔离、模型替换持久化、参数拓扑/解析、
|
||||
总览和提示词提取;真实 ComfyUI 视觉与交互仍待宿主验收。
|
||||
- 阶段 4(完成,`e6217bb557`、`12875f9053`、`3e3e26d2b2`、`2951d610c5`、`7afa7518bc`):
|
||||
模型诊断保留诊断、全局扫描和哈希协调,助手、原生控件选择器、参数方案分别归
|
||||
`ui_node_assistant.js`、`ui_node_model_picker.js`、`ui_node_presets.js`。模型详情保留展示协调,
|
||||
编辑器与高级选择器归 `ui_model_editor.js`、`ui_model_selector.js`。图片工作台仍唯一持有 `wb`
|
||||
和元数据缓存,舞台/缩放/胶片条归 `ui_image_stage.js`,检查器标签归 `ui_image_inspector.js`。
|
||||
素材目录、卡片、详情与节点应用/监听分别归 `ui_materials.js`、`ui_material_cards.js`、
|
||||
`ui_material_detail.js`、`ui_material_application.js`。笔记目录与保存留在 `ui_notebooks.js`,
|
||||
编辑器归 `ui_notebook_editor.js`,画布节点创建归 `notebook_canvas.js`。新增边界与行为检查覆盖
|
||||
以上接线、监听器清理、控件路径、素材应用和笔记保存队列;真实 ComfyUI 视觉仍待宿主验收。
|
||||
- 阶段 5(完成,`e8b50ecaf5`):`web/styles.css` 保留为唯一外部入口,并按原始字节顺序有序导入
|
||||
`web/styles/00-foundation-models.css` 至 `10-model-sources.css`。所有子样式带同一缓存版本;
|
||||
`css_bundle_order.mjs` 校验导入唯一/有序,且子文件拼接 SHA-256 与拆分前完全一致
|
||||
(`d014a9d1826e2c48a46ac72389fe4e5bdd0b2ba85618f653d13852cd93812bc1`)。
|
||||
未引入 cascade layer、动态加载或视觉改版;真实主题、停靠和浮层对比仍需宿主验收。
|
||||
- 阶段 6(完成,`6ad975c1cd`、`9cebdbd994`、`a0c6849878`):配方的工作流规则、配方规范化、
|
||||
图片和存储分别归 `workflow_schema.py`、`recipe_schema.py`、`recipe_images.py`、`recipe_store.py`;
|
||||
模型目录、身份解析、元数据写入和媒体归 `model_catalog.py`、`model_resolution.py`、
|
||||
`model_metadata.py`、`model_media.py`;素材规范化、资产和单一锁/缓存存储归
|
||||
`material_schema.py`、`material_assets.py`、`material_store.py`。路由入口改为显式模块注册,
|
||||
`test_route_manifest.py` 固定 68 个 method/path 对。兼容门面仅保留历史内部测试/调用点,不复制状态。
|
||||
- 阶段 7(完成,`a63a615514`):复核后保留 `ui_prompt_workbench.js` 与
|
||||
`ui_prompt_source_deck.js` 的单工厂闭包边界,避免拆出大量可变状态参数;`ui_model_sources.js`
|
||||
仍是模型来源这一项功能的查询/编辑/持久化协调器;`material_inspector.js` 仍聚焦共享元数据与
|
||||
节点参数检查。发现 `api/utils.py` 确有五类独立职责,已拆为 `path_utils.py`、
|
||||
`media_routes.py`、`translation_routes.py`、`gallery_routes.py`、`folder_types.py`,原文件仅作兼容导出。
|
||||
Python/JavaScript 相对导入图均无环,未发现 `EXTRACTED`/待提取占位或旧模型/配方/素材星号路由导入。
|
||||
最终通过全部 29 个 `.mjs` 检查、89 个 Python `unittest`、全量 `api/*.py` 编译和路由清单检查。
|
||||
本轮未连接运行中的 ComfyUI,因此不声称已完成真实拖放、停靠、主题、媒体释放和点击路径视觉验收。
|
||||
@@ -0,0 +1,133 @@
|
||||
# 工具箱与快捷栏自定义:交给 Gemini 的实施计划
|
||||
|
||||
状态:待实施的产品与工程计划,不代表当前版本已经具备以下功能。
|
||||
基线:`77ae7ce88`;开工时以实际 Git 差异和现有代码为准,不回退后续工作。
|
||||
|
||||
## 目标与范围
|
||||
|
||||
把左下角做成用户可整理的快捷栏:工具箱的工具能拖到栏中,栏中的可移动工具能拖回工具箱,也能调整顺序。
|
||||
同时完成短文字、详细提示的出现时间与位置调整。保留当前紧凑布局、工具箱外框尺寸和现有工具行为。
|
||||
先阅读 `AGENTS.md`、`docs/architecture/frontend.md`、`docs/architecture/update-guide.md`,仅加载本次相关内容。
|
||||
本任务有拖动状态与持久化,按下面阶段实施;不借机重写整个侧栏、换 UI 框架或引入拖拽依赖。
|
||||
|
||||
## 一、交互设计定稿要求
|
||||
|
||||
实施前用简短文字或草图说明:正常、拖出、拖回、排序、容量已满、取消六种状态。
|
||||
按本计划做出完整方案后直接实施;常规视觉细节由 Gemini 决定,不增加多轮审批。若与现有代码冲突,优先保持业务语义。
|
||||
|
||||
### 1. 工具箱是完整目录,底栏是快捷入口
|
||||
|
||||
- 工具箱始终保留所有可用工具。拖到快捷栏代表“固定”,工具箱卡片仍在,并标注“已固定”。
|
||||
- 从快捷栏拖回工具箱代表“从快捷栏移除”,不删除工具、不关闭正在执行的任务、不更改数据。
|
||||
- 第一版固定两个锚点:最左的工具箱、最右的设置。它们不能被拖走,防止用户丢失整理和恢复入口。
|
||||
- 中间最多 4 个可自定义入口,默认依旧是扫描、模型医生、节点助手、素材库,总共最多 6 个按钮。
|
||||
- 工具箱中现有的导入导出中心、提示词工坊、翻译助手、模型来源也能固定到中间位置。
|
||||
- 工具箱卡片顺序固定,快捷栏顺序由用户决定。第一版不做工具箱内部自由排序、分组、分页或自由浮动窗口。
|
||||
- 拖出只接受快捷栏区域;丢到画布、图库、模型卡片或窗口外一律取消,不能生成节点或触发文件操作。
|
||||
- 容量已满时不自动挤走其他工具,提示“快捷栏已满,请先将一个工具移回工具箱”;菜单固定与拖动规则一致。
|
||||
- 重复固定不产生副本;已固定工具拖到快捷栏只调整已有位置。
|
||||
- 提供“恢复默认快捷栏”,只重置本布局,不调用重置插件全部设置的功能。
|
||||
|
||||
### 2. 工具箱内的可发现性
|
||||
|
||||
- 在工具箱空白背景区域设置低干扰、可读的提示:“拖到下方,固定常用工具;拖回这里,收起快捷入口”。
|
||||
- 使用独立的提示/放置区域,不把文字覆盖在卡片上;空间不足时改为底部短提示,不能撑大工具箱。
|
||||
- 保持工具箱外框尺寸,工具增多只让内部列表滚动;底部提示/放置区保持可见,不遮挡卡片和关闭按钮。
|
||||
- 工具卡片悬停或键盘聚焦时显露小拖动把手和“更多”操作入口;不要用常驻的大段教程挤占工具区域。
|
||||
- 拖动快捷入口进入工具箱时,放置区域高亮并显示“松开,从快捷栏移除”;工具箱按钮本身也可作为拖回目标。
|
||||
- 工具箱关闭时,拖到工具箱按钮上约 350 毫秒后自动展开;计时离开即取消。只在确实拖动快捷入口时启用。
|
||||
- 没有完成放置时,不提前改动排列或保存设置。预览插入位置使用占位符/指示线。
|
||||
|
||||
### 3. 前一轮文字、时间与布局调整(本任务必做)
|
||||
|
||||
| 元素 | 第一版参数与行为 |
|
||||
| --- | --- |
|
||||
| 按钮内短文字 | 鼠标停留约 100 毫秒后,以约 120 毫秒的向上淡入替换图标;保持按钮尺寸与点击区域不变 |
|
||||
| 完整说明框 | 从进入按钮开始累计约 600 毫秒后出现;不是短文字结束后再多等 600 毫秒 |
|
||||
| 离开按钮 | 取消未触发的等待,详细提示立即隐藏;图标在约 100 毫秒内恢复,不保留离场延迟 |
|
||||
| 说明框位置 | 默认在按钮上方,间隔约 8 像素;左右限制在实际侧栏边界内,并兼顾视口 |
|
||||
| 排版 | 名称稍突出;正文常规字重、颜色稍淡、自然换行;不把整段解释都加粗 |
|
||||
| 键盘 | focus-visible 立即显示短文字和清晰焦点,详细说明遵循约 600 毫秒等待;失焦立即清理 |
|
||||
| 无悬停设备 | 短文字直接可见;提供点击“更多”的整理方式,不依赖悬停或长按才能使用 |
|
||||
| 减少动态效果 | 保留识别文字,但取消位移动画,遵守已有 prefers-reduced-motion 规则 |
|
||||
|
||||
这些时间是初始设计值,真实浏览器验证时允许小幅调整,最终只在一处定义,不能散落在多个模块。
|
||||
从一个按钮快速移到另一个时,重新计时,不闪现上一个按钮的说明。
|
||||
拖动开始立即隐藏所有按钮提示,拖动期间不显示短文字过渡和说明框;结束后等待新的悬停/聚焦再触发。
|
||||
提示框不能拦截点击、遮挡拖放目标;不要再使用超宽且正文加粗的提示框。
|
||||
当前 tooltip 使用伪元素:若要分开标题/正文或准确限制边界,替换这一组按钮的提示渲染为一个共享提示节点,并移除旧提示属性/规则,避免出现两套提示。
|
||||
不要改动插件所有其他 tooltip。新增可固定工具也必须有短标签、完整名称、说明与中英文翻译。
|
||||
|
||||
### 4. 拖动和普通点击必须分清
|
||||
|
||||
- 鼠标按下不立即拖动,移动超过约 6 CSS 像素才进入拖动态;工具箱优先从把手起拖,卡片正文点击继续打开工具。
|
||||
- 快捷栏支持按住按钮拖动。超过阈值后的 pointerup/click 不能打开工具或触发扫描,即使最后取消也不能误点击。
|
||||
- 执行原有动作只允许来自明确的普通点击或键盘激活;拖动预览不能调用工具 action。
|
||||
- 拖动时保留来源占位、显示小型拖动预览和有效放置提示,不扩大按钮、推动整个侧栏或影响画布缩放。
|
||||
- 拖动过程暂缓工具箱的“点击外部关闭”。与现有 document mousedown 关闭监听统一协调,不能边拖边把目标关闭。
|
||||
- Esc、pointercancel、失焦、关闭插件、语言切换或重建相关视图均取消拖动并恢复原布局;清理指针捕获、全局监听、计时器和预览。
|
||||
- 自动展开的工具箱取消后恢复此前开关状态;原本打开的工具箱保持打开。放置后可保留打开,方便继续整理。
|
||||
- 在插件缩放和停靠模式下统一使用视口坐标与 getBoundingClientRect,不能混用缩放前尺寸计算落点。
|
||||
|
||||
### 5. 不会拖动也能完成整理
|
||||
|
||||
工具箱的“更多”操作提供“固定到快捷栏/从快捷栏移除”;快捷入口提供“左移/右移/移回工具箱”。
|
||||
不要仅提供右键菜单,也不要依赖鼠标。使用真实 button、可读名称、可见焦点;操作后把焦点留在合理的位置。
|
||||
菜单和拖动走同一个布局修改函数,包含相同的容量、去重、固定入口约束;成功放置可用 aria-live 简短播报。
|
||||
|
||||
## 二、工程结构
|
||||
|
||||
### 现有责任与风险
|
||||
|
||||
- `web/modules/ui_sidebar.js`:目前拥有工具箱卡片、侧栏按钮、扫描状态、打开/关闭和语言刷新;它已经很大,新完整拖动实现不要继续塞入 createDOM。
|
||||
- `web/modules/sidebar_actions.js`:目前只认识六个硬编码 DOM ID;自定义后必须让新增快捷工具也能获取标签和用途。
|
||||
- `web/modules/ui_lifecycle.js`:复用视图资源清理机制;不要新建永久全局拖动监听。
|
||||
- `web/styles.css`:找到现有 sidebar-actions、toolbox、tooltip 规则并修改,移除本次取代的规则;禁止追加覆盖包。
|
||||
- `web/modules/ui_update_guide.js`:保持上方手动入口,不因自定义布局或新引导版本恢复自动弹窗。
|
||||
|
||||
### 建议职责拆分(可按仓库命名习惯调整,不为拆分增加空壳)
|
||||
|
||||
1. 工具定义:一个稳定 ID 对应一份名称键、短标签键、说明键、图标和可固定属性;目录和快捷栏共用。动作回调在侧栏装配时显式传入,纯数据模块不读取画布。
|
||||
2. 布局状态:单独的小模块负责默认值、去重/过滤、增删排序、容量与存储;只保存可移动 ID 的顺序,固定两端由视图添加。
|
||||
3. 整理视图:独立模块负责拖动、放置预览、菜单与取消清理。只在有效放置/菜单确认时调用布局修改接口。
|
||||
4. 现有 sidebar_actions 模块负责短标签和说明提示,可改为接收工具定义;禁止同时保留两套工具元信息和两套点击动作。
|
||||
|
||||
不要直接搬移或克隆工具 DOM 来实现固定:这会带走旧关闭逻辑、造成重复 ID 或丢失状态。
|
||||
每个工具在两处调用同一个动作。保留现有隐藏其他面板、初始化、扫描中状态和错误反馈;不要通过隐藏按钮的 `.click()` 间接调用。
|
||||
特别核对 `setScanButtonState`、`anomalous-scan-btn` 及语言刷新的所有调用方:扫描运行中移动入口,不能丢失进度、重复启动、产生失效引用。
|
||||
DOM ID 保证唯一;两个可见入口用稳定 toolId 关联。当前未固定的工具也不能因为入口不在底栏而失去业务状态。
|
||||
|
||||
### 持久化与兼容
|
||||
|
||||
- 使用一个带 schemaVersion 的独立 localStorage 键,例如 `anomalous_shortcut_layout_v1`,内容仅为 `{ schemaVersion: 1, pinned: [...] }`。
|
||||
- 首次使用采用原来的四个中间入口;显式的空数组是合法选择,不得又补回默认值。
|
||||
- 读取时拒绝错误结构/未知版本;去掉未知工具 ID、重复项和固定锚点,限制容量;删除工具或升级后仍能打开界面。
|
||||
- 不保存 DOM、翻译文本、回调代码或文件路径;不修改后端配置、工作流或素材数据。
|
||||
- 写入失败保留当前会话布局,准确提示刷新后可能丢失;禁止伪报“已保存”,也不能让插件打不开。
|
||||
- 第一版只在当前浏览器配置中持久化,不实现账户同步、多设备同步或布局导入导出。
|
||||
|
||||
## 三、不能改变的产品边界
|
||||
|
||||
- 导入导出快捷入口只打开现有已验证的 AMB0/AMB1 中心,仍是同一个中心;不能恢复配方、素材库、提示词工坊的独立文件导入导出。
|
||||
- 保留素材提示词自动同步、来源标注和不可在工坊删除来源卡片的规则。
|
||||
- 不修改图片/工作流哈希、模型识别、模型扫描内容、节点写入语义或用户文件。
|
||||
- 不新增全局自动教程;功能上线后再更新引导内容配置和双语文案,仍由用户点击感叹号查看。
|
||||
|
||||
## 四、实施顺序与验收
|
||||
|
||||
1. **设计与状态**:说明六种交互状态,提取工具定义和布局纯逻辑,验证默认、空数组、去重、容量、损坏数据及存储失败。此阶段保留旧界面可运行。
|
||||
2. **无拖动整理**:先让目录与快捷栏共用动作,通过菜单完成固定、移除、排序、恢复默认和刷新恢复;核对扫描状态、语言刷新、唯一 DOM ID。验证通过即本地提交。
|
||||
3. **拖动与提示**:接入同一状态接口,实现阈值、放置、取消和文字/说明时间布局;验证后再提交,不另造第二套状态。
|
||||
4. **交付**:仅更新改变的架构契约与用户可见更新说明。汇报实际验证、未测环境及本地提交,不推送 GitHub。
|
||||
|
||||
自动测试加载真实布局模块与交互模块,只模拟 DOM/存储边界。拖动至少覆盖成功放置、移出目标取消、Esc/父视图关闭清理、拖后不触发点击。
|
||||
现有相关测试是 `node --experimental-vm-modules tests/update_guide.mjs`;扫描动作和已验证导入导出入口发生接线变化时追加相应回归,不默认跑全部后端测试。
|
||||
新增测试确保被 Git 跟踪。语法/模块链接检查不能代替下面的浏览器操作:
|
||||
|
||||
- 工具箱 → 快捷栏、快捷栏 → 工具箱、栏内排序、重复固定、满栏拒绝、取消拖动;默认工具都能打开且只执行一次。
|
||||
- 刷新、恢复默认、所有可移动项移除、扫描中移动入口、中英文切换;工具箱与设置始终可达。
|
||||
- 快速掠过不闪说明,停留出现分层文案,边缘按钮提示不越出侧栏;拖动无提示遮挡。
|
||||
- 普通窗口、窄侧栏、插件缩放、侧边停靠;工具箱尺寸稳定、内部可滚动、拖放坐标准确。
|
||||
- 键盘菜单能完成同样操作;点击感叹号才显示更新引导。至少验证常用主题,未测环境如实说明。
|
||||
|
||||
按 AGENTS.md 及时保存阶段快照;若额度将尽或任务中断,保存有明确“验证待完成”说明的恢复快照,而不是无提交地把全部工作留到最后。
|
||||
@@ -0,0 +1,88 @@
|
||||
# 前端设计与 Gemini 协作方案
|
||||
|
||||
更新:2026-09-16。取代 9 月 9–11 日的前端视觉、布局建议,后续直接更新本文,不再按日期追加同类建议文件。
|
||||
|
||||
这是设计任务说明;工程规范以根目录 [AGENTS.md](../../AGENTS.md) 为准。具体任务若已有更新的专门方案,以专门方案为准。
|
||||
|
||||
## 1. 当前起点
|
||||
|
||||
本次核对基于 `3f71ddfcb` 的代码与计划,没有重新进行浏览器视觉验收。
|
||||
|
||||
- 配方已经有独立容器和工具栏,笔记有自己的标题,不再把“拆开创作工作台”列为待办。
|
||||
- 提示词工坊已经独立。继续使用现有工坊,不恢复旧嵌入式组合面板,也不拿早期的简单组合设想限制现在已有的能力。
|
||||
- 文件夹与配方覆盖层的定位问题已有修复,不重复下发修复任务。
|
||||
- 工具箱已有入口;快捷栏自定义是**待实施计划**,见 [工具箱与快捷栏方案](customizable-tool-shortcuts.md)。不要把计划当作已经实现的功能。
|
||||
- 当前配方包导入按钮处于禁用状态。设计可以解释不可用原因,不能为了演示效果直接启用、伪造成功或隐藏这个限制。
|
||||
|
||||
以上用于避免重复劳动,不代表所有关联功能已经验收通过。旧审查报告中的问题需要复现后判断,不能整份搬成当前待办,也不能直接宣布全部解决。
|
||||
|
||||
## 2. 怎样发挥 Gemini 的优势
|
||||
|
||||
让 Gemini 负责视觉层次、排版、密度、图标、过渡和响应式细节,并完成对应前端实现。不给每个像素下指令,也不要求每个小调整都等待批准。
|
||||
|
||||
任务边界和操作语义先明确:用户从哪里进入、想完成什么、写入什么、如何取消、完成后去哪里。Codex 可以负责梳理这些约束与检查数据、节点和生命周期;Gemini 做实现时也必须覆盖它们,不能把操作闭环全部留给下一位补。
|
||||
|
||||
**每轮交付一条完整操作流程。** “把工具箱做漂亮”范围模糊;“让用户从工具箱固定一个工具,再移除或取消,且任何时候都能恢复默认”才是可完成的任务。
|
||||
|
||||
## 3. 统一的设计方向
|
||||
|
||||
目标是清楚、紧凑、有质感的 ComfyUI 创作工具。图片和用户内容是视觉重点,装饰只辅助识别。
|
||||
|
||||
| 区域 | 设计职责 |
|
||||
| --- | --- |
|
||||
| 顶部导航 | 模型、图库、工作流配方;进入和返回的位置稳定 |
|
||||
| 模型文件夹 | 只负责模型目录,不承担全局导航 |
|
||||
| 左下工具区 | 默认保留素材库;工具箱是完整目录,快捷栏是常用入口。自定义行为遵循专门方案 |
|
||||
| 列表工具栏 | 搜索、筛选、结果数、主操作;窄窗口可换行,控件不能互相挤没 |
|
||||
| 内容卡片 | 图片、名称、类型和一项主操作。技术细节按需要展开,次操作可集中到“更多” |
|
||||
| 详情与编辑 | 返回路径明确;区分查看、编辑、应用,明确应用到哪个节点或画布 |
|
||||
|
||||
不要为了统一外观强行让所有卡片显示同样字段。模型用于选择资产,配方用于复用工作流,素材用于复用内容,信息重点各不相同。
|
||||
|
||||
外观由 Gemini 在现有主题内自行发挥:统一按钮层级、间距、圆角与文字密度;选中、警告、禁用和键盘焦点要清楚。普通按钮不加持续发光或跳动。两套主题共享布局与操作规则,不能各自长出一套交互。
|
||||
|
||||
不把新的全站导航重构与本轮工具箱改动捆绑实施。发现其他页面需要调整时,写一句后续项即可。
|
||||
|
||||
## 4. 防止只考虑一个局部
|
||||
|
||||
动手前用几行列出本次操作链,并填下面这张表。只检查与本次变更有关的部分,不做每轮全仓审查。
|
||||
|
||||
| 必须想清楚的事 | 交付时需要回答 |
|
||||
| --- | --- |
|
||||
| 入口与出口 | 从哪个页面进入?关闭、返回、再次打开后分别是什么状态? |
|
||||
| 正常与边界状态 | 加载、空内容、筛选无结果、失败、禁用、取消是否可理解?没有此状态的说明不适用 |
|
||||
| 关联影响 | 改了共享按钮或浮层,哪些相邻页面、主题、提示框会受影响? |
|
||||
| 操作对象 | 当前改的是快捷入口、素材数据、节点参数还是画布?文案是否说清楚? |
|
||||
| 尺寸与输入 | 普通窗口、侧边停靠、长中文/英文、鼠标与键盘是否能完成同一任务? |
|
||||
| 离开后的行为 | 悬浮预览、拖动、视频和请求会不会在关闭后继续活动或写错对象? |
|
||||
|
||||
例如“更多”菜单变漂亮,仍必须考虑靠右时是否出界、被父容器裁切、滚动时是否漂移,以及 Esc 关闭的是菜单还是整个浏览器。选择一个代表页面打磨公共样式后,再核对受影响页面,不要求一次重做它们。
|
||||
|
||||
## 5. 性能怎么兼顾
|
||||
|
||||
- 默认采用静态布局与局部短过渡。大面积模糊、连续光效、鼠标移动驱动全页动画必须有实际价值,否则不用。
|
||||
- 大列表继续按现有分页、缩略图和懒加载策略工作;不能为视觉预览一次性加载全部原图、详情和视频。
|
||||
- 工具提示随内容和交互更新,不为一小段文字增加持续轮询。隐藏页面的事件、预览和请求按照已有生命周期释放。
|
||||
- 验证本次增加的成本:是否多了持续任务、重复请求或重复监听,滚动和拖动是否出现明显卡顿。需要声称性能提升时提供同一条件下的测量;没有测量就不报百分比,也不猜显存变化。
|
||||
|
||||
## 6. 推荐工作顺序
|
||||
|
||||
**第一轮:完成工具箱与快捷栏专门方案。** 这是明确的当前任务单;视觉与交互一起完成,包含正常放置、拖回、取消、重复固定、容量已满和恢复默认,不顺手改其他页面导航。
|
||||
|
||||
**第二轮:统一受影响的公共控件。** 以工具箱的按钮、提示、选中和禁用状态为起点,检查它们在模型、配方、素材库中的实际使用。修改原规则,清掉本次替代的旧声明。
|
||||
|
||||
**第三轮:逐页优化信息密度。** 先选一个真实使用频繁的页面,完整处理列表、详情、返回和失败反馈,再决定下一页。无需为了执行本文件同时美化全插件。
|
||||
|
||||
每轮完成后更新状态,删除已完成待办。验证发现的新问题直接修复;超出本轮范围的只记录具体触发条件,避免积累泛泛的“以后再优化”。
|
||||
|
||||
## 7. 可直接发给 Gemini 的任务模板
|
||||
|
||||
> 先阅读 GEMINI.md、AGENTS.md,以及 docs/plans/frontend-design-brief.md。本轮只完成【具体操作流程】,具体规则以【对应方案】为准。
|
||||
>
|
||||
> 先简述入口、正常操作、取消/失败、返回路径,以及会受影响的相邻界面,然后直接实施。颜色、排版和细节由你在现有主题内决定,不必逐项询问。
|
||||
>
|
||||
> 交付完整交互,不只做正常状态的截图。保留真实数据和操作语义,覆盖相关的窄窗口、主题、键盘与关闭路径。发现接口或业务阻塞时明确说明,不能用演示数据、假成功或静默隐藏功能替代。
|
||||
>
|
||||
> 完成后用简短清单说明:改变了什么、实际验证了哪些路径、哪些未验证、剩余的具体问题和本地提交。验证范围与改动相称,不默认跑全部测试;未完成项不得写成已完成。
|
||||
|
||||
这样用户主要判断“是否顺手、是否好看”,无需每次替实现者补齐所有边界情况。
|
||||
@@ -0,0 +1,34 @@
|
||||
# 后续构想:让 AI 从图库中帮助创作
|
||||
|
||||
记录日期:2026-09-18。
|
||||
**状态:仅保存想法,现阶段不开发。本文只描述想实现什么,不规定代码、接口或技术方案。**
|
||||
|
||||
## 核心想法
|
||||
|
||||
图库不仅用于看图,也积累了生成图片时使用的提示词、模型、参数和工作流。未来允许用户连接自己选择的 AI 服务,从这些创作记录中总结经验,推荐值得尝试的工作流组合。
|
||||
|
||||
希望它能回答:“根据我喜欢的结果、已经拥有的模型和现在想画的内容,下一次可以怎样搭配?”
|
||||
|
||||
## 希望提供的体验
|
||||
|
||||
- **分析选中的作品**:选择一张或一组图片,查看它们使用了哪些模型、提示词和参数,有哪些共同点与差异。
|
||||
- **推荐工作流组合**:结合创作目标与现有模型,推荐可尝试的节点、模型、提示词和参数组合,并解释推荐理由、参考了哪些作品、需要补充哪些条件。
|
||||
- **总结个人偏好**:结合收藏、喜欢/不喜欢及主动评价,帮助用户发现自己偏好的风格和搭配,而不是把生成次数最多的组合当成最好。
|
||||
- **比较与改进**:对比满意与不满意的结果,提出值得尝试的调整方向;通过后续试图和反馈,逐步判断建议是否适合用户。
|
||||
- **整理可复用经验**:把用户认可的总结整理成提示词笔记、素材或工作流配方,方便再次使用。
|
||||
- **探索更多玩法**:以后可以考虑用自然语言寻找历史作品、归纳某个模型的常用搭配、为新题材寻找可参考的旧工作流。逐项评估,不要求一次做完。
|
||||
|
||||
## 对结果的期望
|
||||
|
||||
推荐应当具体、可解释,区分“图库记录中的事实”和“AI 提出的尝试建议”。缺少必要模型或节点时提前说明,用户确认后再决定是否保存或应用。
|
||||
|
||||
仅凭参数不能保证找出“最佳工作流”,也不能把相关性当作某个参数造成画面改善的证明。有图片分析能力时可结合实际画面;没有时应明确仅依据参数与用户评价。
|
||||
|
||||
## 保留的边界
|
||||
|
||||
- 图库中并非每张图片都带有完整参数或工作流;缺失信息要明确标注,不让 AI 编造来源和配置。
|
||||
- 用户主动选择分析范围,并能在发送前了解将交给外部 AI 的图片、提示词和工作流内容;不默认上传整个图库。
|
||||
- 分析与应用分开:不自动改写现有工作流、不覆盖记录、不自动下载模型或启动生成。
|
||||
- 用户能控制分析规模与费用;现有看图和素材功能保持独立,不连接 AI 也能正常使用。
|
||||
|
||||
这是一项长期探索方向,当前优先完成现有功能收尾。真正启动时再讨论最小可用范围。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 素材库:保存画布选中节点
|
||||
|
||||
记录日期:2026-09-18。
|
||||
**状态:后续需求,仅记录,本轮不开发。**
|
||||
|
||||
## 想做什么
|
||||
|
||||
在 ComfyUI 画布中选中一个或多个节点,通过一个方便的入口直接保存到素材库,方便以后复用,不必先生成图片或建立工作流配方。
|
||||
|
||||
- 单选:将这个节点及其当前参数保存为一份素材。
|
||||
- 多选:将全部选中节点作为一个整体,保存为一份素材,不拆成多个独立条目。
|
||||
- 保存选中节点之间的内部连线;与未选中节点相连的部分不自动扩展收录,不能留下指向不存在节点的无效连线。
|
||||
- 保存不改变当前画布、节点参数或现有连接。
|
||||
|
||||
## 建议的最小交互
|
||||
|
||||
- 优先考虑节点或画布右键菜单中的“保存到素材库”,单选和多选共用入口;具体入口接入方式开发时核对 ComfyUI 能力。
|
||||
- 轻量确认框显示“已选 N 个节点”,填写名称即可保存,标签可选;不强制提供封面。
|
||||
- 未选中节点时不允许保存;成功提示只在实际保存完成后出现,并提供查看新素材的入口。失败保留输入,便于重试。
|
||||
- 点击保存时固定节点选择与参数快照,后续切换选择不改变本次保存对象。
|
||||
|
||||
## 开发时注意
|
||||
|
||||
- 先核对现有节点素材、参数素材的保存与应用能力,复用素材库的存储、列表、详情和校验流程,不另建一套收藏系统。
|
||||
- 当前图片/配方来源的保存接口不能直接假定支持画布快照;需要改协议时,明确新增来源的校验与兼容规则,不伪造图片来源。
|
||||
- 单节点素材应能继续参与已有节点参数应用;多节点素材保留整体结构。首次开发需明确复用入口,保存成功后必须能在库中查看并通过相应流程使用。
|
||||
- 先做好单选、多选、内部连线、外部连接边界、无选中节点和保存失败的验证;不借此扩展复杂节点组编辑器或自动收集依赖。
|
||||
|
||||
正式开发前遵循 [AGENTS.md](../../AGENTS.md),阅读 [素材库架构](../architecture/material-library.md),按最新实现细化方案。本文件不表示功能已完成。
|
||||
@@ -0,0 +1,157 @@
|
||||
# 模型来源中控:链接管理范围与后续方案
|
||||
|
||||
更新:2026-09-18。实施基线:`1b2013fe69`;接手时先核对最新代码。
|
||||
**状态:2026-09-18 已完成“交给 5.6Sol”范围的代码与自动化验收;真实 ComfyUI 后端需重启后再做最终页面复核。** 本文是唯一交接计划,后续原地更新。
|
||||
|
||||
## 交给 5.6Sol:本轮只做目录范围对齐与基础组件折叠
|
||||
|
||||
### 目标与已确认原因
|
||||
|
||||
来源中控仍只管理链接。补齐本地模型列表,让用户通过现有文件夹管理决定纳入哪些目录,同时将基础组件默认收起,降低整理负担。
|
||||
|
||||
已确认的代码问题:
|
||||
|
||||
- 中控 `fetchAllLibraryModels()` 调用 `/anomalous/all_scan_models`,后端 `_collect_all_scan_models()` 只读分类模式的 `get_active_folder_types()`,没有遵循当前物理目录模式。
|
||||
- 本次本地配置中,物理目录模式的 `clip` 已开启,但分类配置的 `text_encoders` 关闭。这能解释本地库遗漏 CLIP;不能因此认定所有缺项都只有这一种原因。
|
||||
- 此接口只枚举 `.safetensors/.ckpt/.pt/.bin/.sft`,遗漏 `.gguf/.pth`,且扩展名比较区分大小写。
|
||||
- 上轮提交只补了工作流收集、基础组件标签与对应类别的元数据读取,没有修复本地目录范围。
|
||||
|
||||
### 一、目录范围与文件格式
|
||||
|
||||
1. **本地库遵循文件夹管理当前模式。** 分类模式按分类勾选;物理目录模式按物理目录勾选。隐藏目录不遍历、不读取元数据;不自动开启 CLIP 等目录,不改用户配置。
|
||||
2. 复用 `api/folder_types.py` 的模式、启用目录规则。若需提取目录枚举,放在该职责内,返回真实的 `type/path_idx/base_dir`;不能只拿物理目录名当模型类别。物理模式下同一类别可对应多个根目录,必须逐根判断,不能把启用其中一个解释为全部启用。
|
||||
3. 对同一实际根目录进行规范化去重,保留可被后续接口解析的类别与路径索引。不要按 basename 合并模型;不同目录里的同名文件是独立条目。
|
||||
4. 补齐此列表的 GGUF/PTH,并统一大小写判断。仅表示“可列出并记录链接”,不代表这些格式已支持元数据解析、身份恢复或云端识别。不要直接扩大全局 `MODEL_EXTENSIONS` 后让所有读写路径被动接受新格式。
|
||||
5. `/all_scan_models` 同时被 `ui_model_selector.js` 使用。先检查两处调用;保持返回结构、分页与 `limit=0` 语义,验证选择器没有因此丢项或出现重复。确需区分调用用途时再加最小兼容参数,不先造专用数据库或另一套扫描系统。
|
||||
6. **当前工作流仍列出实际引用的模型**,不被本地库隐藏目录设置抹掉。文件夹设置控制库盘点范围,不能让分享工作流时遗漏依赖。
|
||||
|
||||
### 二、基础组件默认折叠
|
||||
|
||||
- 工作流和本地库都保留两个显示组:主要模型直接显示;基础组件放进默认收起的 **“基础组件(高级) · N”**。使用可键盘操作的折叠控件,不再加一层弹窗。
|
||||
- 本轮基础组件固定为现有策略已覆盖的 `clip/text_encoders/clip_vision/vae/vae_approx`。Checkpoint、扩散模型、LoRA、ControlNet 留在主要列表。其他已启用类别维持原有展示,避免顺手建设全站“核心/非核心”分类系统。
|
||||
- 展开状态由当前中控会话持有,筛选、编辑、保存后的刷新应保留;重新打开默认收起。折叠时不要生成所有组件行的 DOM。
|
||||
- 无来源的基础组件显示 **“未填写(可选)”**,不警告、不计入主要模型“来源待补充”数量;已有来源照常查看、修改和保存。
|
||||
- “全部/已填写来源/来源待补充”筛选作用于主要模型;组件组独立于这三个来源状态筛选,展开后显示全部组件。搜索同时作用于两组,折叠栏数量随搜索更新;文案明确“基础组件来源可选,不计入待补”。
|
||||
- 工作流里确定缺少本地文件的组件,折叠栏仍显示 **“缺少本地文件 × 项”**;该警告按当前范围全部组件计算,不被搜索或来源筛选隐藏。动态下拉选项未知不等于文件缺失,保留上轮判断保护。
|
||||
- 底部统计明确标为“主要模型来源:已填写 X / Y”,使用当前搜索与筛选后的主要列表;组件数量单列在折叠栏,不把未填可选来源算成未完成任务。
|
||||
- 无主要模型但存在组件时,保留组件入口;不能显示“未检测到任何模型”。目录未纳入范围时提供简短提示“本地库范围跟随设置 → 文件夹管理”,不增加重复配置界面。
|
||||
- 所有新增文案同步中英文。使用现有样式变量和中性标签,不加发光、动效或强提醒。
|
||||
|
||||
### 三、保存、性能与职责边界
|
||||
|
||||
- 折叠只影响显示,不能删除已有来源或改变底层模型集合。保存到工作流、生成 Note 保留当前整份工作流语义;复制摘要应明确当前显示范围,不能因折叠悄悄改成导出所有内容。
|
||||
- 本地保存继续使用准确的 `type/path_idx/subfolder/filename`。未定位实际文件的工作流组件只允许记录工作流链接,不得猜测路径写入 `.civitai.info`。
|
||||
- 保留人工链接、原文件名、封面和已有 info。打开、展开、筛选均不触发云端扫描、哈希计算或附属文件写入。
|
||||
- 列表枚举继续放在已有工作线程路径;折叠、搜索只处理已加载数据,不反复全盘读取。加载成功但为空、加载失败、尚未加载要能区分;打开时若指定 library 范围也须正确加载。
|
||||
- `ui_model_sources.js` 已超过规模阈值:优先把目录结果整形、分组/筛选等有明确职责的逻辑提取到来源中控专用模块,弹窗保留生命周期与协调。只在本轮需要处提取,不做全仓拆分,也不把业务塞入通用 utils。
|
||||
|
||||
### 修改落点
|
||||
|
||||
| 位置 | 本轮责任 |
|
||||
| --- | --- |
|
||||
| `api/folder_types.py`、`api/model_catalog.py` | 当前模式对应的目录范围、路径去重、列表格式支持 |
|
||||
| `web/modules/ui_model_sources.js` 及必要的来源专用模块 | 组件折叠、分组筛选、统计、空态与加载状态 |
|
||||
| `web/modules/model_policies.js` | 复用现有类型判断,避免改动重命名或身份恢复策略 |
|
||||
| `web/modules/locales.js`、`web/styles/10-model-sources.css` | 中英文文案、局部折叠布局 |
|
||||
| `tests/model_sources_hub.mjs`、`tests/model_source_components.mjs` | 更新为新交互,同时保留准确定位与保存保护测试 |
|
||||
| 对应 Python 测试 | 使用临时目录验证目录模式、过滤与去重,不能拿真实模型做写入夹具 |
|
||||
|
||||
### 验收清单
|
||||
|
||||
1. 物理模式开启 `clip`、分类配置关闭 `text_encoders` 时,库仍显示所选物理目录里的 CLIP;反向关闭物理目录时不枚举它。分类模式按自身配置工作。
|
||||
2. 多根目录只启用其中一个、路径别名指向同一实际根、不同根有同名文件,均得到正确范围与去重结果;隐藏根目录零遍历、零元数据读取。
|
||||
3. 无来源模型仍出现;GGUF/PTH 及大写扩展名可列出。现有格式、分页、选择器调用继续可用。
|
||||
4. 基础组件初始折叠,展开后可查看/编辑;切换筛选或保存不丢展开状态与输入内容。主要待补计数不含组件;搜索、仅有组件、全空、加载失败均有明确结果。
|
||||
5. 折叠时仍能看到组件缺文件告警;“无来源”和“文件缺失”独立。搜索无匹配不能抹掉当前工作流的缺文件告警。
|
||||
6. 折叠与筛选不清空已保存链接。已定位文件准确保存;未知目标不写本地;关闭窗口后迟到请求不重新渲染。
|
||||
7. 跑上述两组 JS 测试和对应 Python 目录枚举测试。实际 ComfyUI 验证两个范围、展开/收起、筛选搜索、窄窗口和原有选择器;写入/缺失场景用隔离夹具,不清空真实链接来制造测试条件。
|
||||
8. Python 改动需重启 ComfyUI 才生效;只有前端刷新不能证明新接口已加载。不要强停有任务的实例;如暂不能验证新后端,明确报告缺口。
|
||||
9. 完成后审查差异、更新本文状态与最窄的架构专题、本地提交,不推送。架构中的目录范围说明也要同步区分分类与物理模式。
|
||||
|
||||
### 本轮实施记录(2026-09-18)
|
||||
|
||||
- 已增加文件夹管理范围的统一根目录枚举,来源库按当前分类/物理模式取根,保留 `type/path_idx`、真实路径去重,并只在来源库列表补充大小写无关的 GGUF/PTH。
|
||||
- 已提取来源中控专用的数据整形与分组模块;工作流和本地库的基础组件默认折叠,主要模型筛选、组件搜索计数、缺文件提示和当前可见范围复制各自保持独立语义。
|
||||
- 自动化验证:来源中控两组 JS 专项测试、95 项 Python 测试、CSS/本地化契约通过;临时目录覆盖物理/分类范围、隐藏根零遍历与零元数据、路径别名、同名文件和 GGUF/PTH 大小写。
|
||||
- 已在运行中的 ComfyUI 页面验证默认折叠、键盘展开、来源筛选不隐藏组件、搜索计数、主要模型统计与本地库范围提示;并在独立 Python 进程按真实物理配置确认 7 个启用根,其中 `text_encoders/path_idx=1` 正确对应已开启的 `clip` 根。运行实例未强停;Python 路由仍是重启前模块,目录范围新逻辑须在安全重启后复核。当前浏览器控制器不支持临时窄视口覆盖,窄窗口实机检查仍待补。
|
||||
|
||||
### 本轮不做
|
||||
|
||||
多来源切换、HF 抓图/info、平台多选、一键补全、扫描结果共享、全站格式支持和目录设置重做均不做。下文“链接可信度”中的历史识别/保存问题另列后续,不因本次计划一并扩张;若阻断上述验收,才做最小必要修复并说明。
|
||||
|
||||
## 已完成:工作流基础组件链接展示(2026-09-18)
|
||||
|
||||
- 复用模型类别判断,显示文本编码器、VAE、预览 VAE、视觉编码器标签;纳入无扩展名的组件选项及 PTH/GGUF 文件引用。
|
||||
- 来源待补充使用普通提示,与本地缺失警告独立;保留手动填写和保存到工作流的能力。
|
||||
- 基础组件只按对应类别、明确相对路径读取本地元数据,不搜索其他类别或进行云端扫描。不能定位实际文件时不提供本地附属文件写入;无扩展名选项、后端尚不支持定位的文件格式仍可保存工作流链接。
|
||||
- 本地库目录范围、封面、文件名保持现状。大于 16 项的组件分批读取,覆盖后端请求上限。
|
||||
- 验证:来源中控与组件专项测试通过;真实 ComfyUI 页面确认 CLIP/VAE 标签和已有链接正常。真实库当前没有待补来源条目,缺失、手动填写和保存分支使用隔离夹具验证,未改写用户链接或画布。
|
||||
|
||||
## 当前方向:先把链接管好
|
||||
|
||||
中控现阶段定位为模型链接管理页,负责查看、填写、修改、打开与保存来源链接。保留已有工作流/本地库范围与各自保存含义;不因为缩小范围顺手删除现有功能。
|
||||
|
||||
下次实施优先解决与链接可信度直接相关的问题:
|
||||
|
||||
- 无来源的模型必须能够被看到并补链接。“没有来源链接”不能成为排除模型的条件。
|
||||
- 区分“未填写来源”和“本地缺少模型文件”,不把两者都显示成缺失模型。
|
||||
- 自动识别失败返回的搜索页只能作为“去搜索”入口,不能保存为已识别来源、显示识别成功或计入已补全数量。当前 `autoDetectModelSource()` 的搜索回退及调用方提示需要一起检查。
|
||||
- 保存成功以实际写入结果为准;明确“保存到本地”和“写入当前工作流”的区别。无法确定本地文件身份时,不凭同名文件猜测写入位置。
|
||||
- 保留手工录入的合法网页链接,不把用户填写的地址称为“已验证官方来源”。打开链接不隐式抓图、覆盖介绍或启动扫描。
|
||||
|
||||
用户报告“缺失来源的模型没有显示”,当前静态代码没有直接按空链接排除列表项。本次已确认目录模式不一致与格式遗漏,见上面的实施范围;其他来源识别与保存问题仍需分别复现,不将它们统称为已修复。
|
||||
|
||||
## 暂缓一:模型详情的多来源入口
|
||||
|
||||
双链接可行,但应理解为“同一模型有多个来源”,不是切换到另一份模型数据。以下是后续建议,尚未实现。
|
||||
|
||||
- 只有一个来源时保持现有入口;两个来源时,详情的来源区域并列显示 `Civitai`、`Hugging Face` 两个文字按钮。无需为了两个链接引入整个页面的切换标签。
|
||||
- 如果确实需要默认入口,可提供“首选来源”单选;切换只影响默认打开哪个链接,不改变封面、介绍、模型文件、哈希或扫描设置。
|
||||
- 第三个及更多来源再考虑菜单,不预先建设无限多平台管理器。无链接的平台不显示空按钮。
|
||||
- 当前 `civitai_url` 是独立字段,`source_url` 是通用自定义来源,不能假定后者必然是 HF。初版可优先复用现有字段;确需额外首选来源字段时,单独定义兼容规则。
|
||||
- 多个链接并存不证明文件一致。确认匹配仍依赖现有模型身份规则;不能根据名称近似自动合并模型。
|
||||
|
||||
不建议首版让“切换来源”同时切换整套封面和 info:这会引入两套详情状态、缓存和失败回退,超出当前链接管理范围。
|
||||
|
||||
## 暂缓二:扫描来源的单选/多选
|
||||
|
||||
放在现有扫描向导或相关设置里,不在中控再做另一套扫描控制台。
|
||||
|
||||
明确区分三件事:
|
||||
|
||||
| 选项 | 含义 |
|
||||
| --- | --- |
|
||||
| 模型范围 | 扫描哪些本地模型,沿用现有选择能力 |
|
||||
| 来源平台 | 去哪些已支持的平台获取信息,可单选或多选 |
|
||||
| 首选链接 | 默认打开哪个已保存来源,与扫描平台互不联动 |
|
||||
|
||||
推荐平台选择采用复选框:只勾一个就是单来源,勾多个就是多来源,无需再增加“单选模式/多选模式”开关。只展示真正实现并可用的提供方,不能提前让 HF 扫描成为可点击的空功能。
|
||||
|
||||
多平台补全规则另行实施时必须一起确定:优先复用本地信息、只补缺项、保留人工内容、一个平台失败不丢其他结果、明确取消语义。已有链接不等于已有封面;已有 info 文件也不等于每个字段完整。
|
||||
|
||||
## 暂缓三:中控与扫描共享结果
|
||||
|
||||
两者共用现有模型附属信息读写能力,不建立中控专用数据库,也不让两个界面分别维护一份权威副本。
|
||||
|
||||
- 向导保存结果后,中控在打开时或收到完成通知后重新读取;不用持续轮询所有模型。
|
||||
- 中控保存的手工链接,向导可读取并保护。链接只说明用户记录的来源,不代表扫描器已经支持解析该平台。
|
||||
- 如增加“补全缺失来源”,仅复用已有扫描任务,限定当前范围并显示真实成功/未找到/失败结果,不自动扫描整个模型库。
|
||||
|
||||
## 暂缓四:HF 信息补全
|
||||
|
||||
- 工作流基础组件展示已完成,见上文;不据此扩大本地库目录范围或重做全站模型类型系统。
|
||||
- 基础组件保留原文件名,不强制提供封面;没有封面时使用类型图标。
|
||||
- 填写 HF 链接时保留现有封面、介绍和 Civitai 信息,不将自定义链接写进 Civitai 身份字段。
|
||||
- 将来如实现 HF 信息读取,先只读明确仓库的模型卡与有明确声明的缩略图;缺失时保留本地信息。不做自动抓任意 README 图片、全网找封面或无限平台适配。
|
||||
- 图片和介绍的来源与下载链接可以不同,但应可辨别,且不能把仓库级说明误当成已验证的具体文件信息。
|
||||
|
||||
参考:[HF 模型卡官方说明](https://huggingface.co/docs/hub/model-cards)。这只是后续技术参考,不表示本项目已支持自动抓取。
|
||||
|
||||
## 后续实施顺序
|
||||
|
||||
1. 先完成本文“交给 5.6Sol”范围:目录模式对齐、列表格式补齐、基础组件折叠与统计。
|
||||
2. 再单独核查历史识别结果与保存反馈,完成链接管理的基本用途。
|
||||
3. 确有需要时再做模型详情双来源入口;此时仍不联动封面或扫描。
|
||||
4. 最后评估扫描结果共享、平台多选及 HF 元数据读取,逐项实施,不一次打包上线。
|
||||
|
||||
实施时遵循 [AGENTS.md](../../AGENTS.md),核对最新代码和相关测试;本文不替代架构契约,也不授权自动启用所有暂缓功能。
|
||||
@@ -1,181 +0,0 @@
|
||||
# 📖 Error & Experience Summary (开发避坑与经验总结)
|
||||
|
||||
This document serves as an architectural retrospective and UX diagnostic log for the development of the Anomalous Model Browser.
|
||||
这份文档旨在记录 Anomalous 插件开发过程中的架构演进、UX 设计教训以及核心 Bug 排查经验。
|
||||
|
||||
---
|
||||
|
||||
## 1. UX Scroll Traps (交互嵌套滚动陷阱)
|
||||
**The Problem (问题)**: In the Notebook modal, the translation editor had a `max-height` and `overflow-y`. Since the parent modal was also scrollable, it created a "Scroll Trap". Hitting the bottom of the editor unpredictably transferred the scroll event to the outer body.
|
||||
(在笔记本弹窗中,由于翻译编辑器固定了最大高度并允许滚动,导致与父级弹窗的滚动条发生冲突。用户滚动到底部时会突然把外层页面卷走,体验极差。)
|
||||
**The Solution (方案)**: Removed the `max-height` and internal scrollbar. Letting the translation editor expand naturally utilizing the primary window scrollbar guarantees a continuous experience.
|
||||
(彻底移除内部最大高度和滚动条。让编辑器自然撑开,统一使用最外层的主滚动条,保证心流不断档。)
|
||||
|
||||
## 2. Artificial Metadata Pollution (伪造数据的反噬)
|
||||
**The Problem (问题)**: To make the "Base Model" dropdown look complete, the backend artificially injected hardcoded architectures (`SDXL`, `HunyuanVideo`) even if the user didn't own them. This confused users.
|
||||
(为了让“模型架构”下拉菜单看起来更“丰满”,后端硬编码注入了一些用户根本没下载过的新架构。结果让用户极度困惑,以为自己眼花了。)
|
||||
**The Solution (方案)**: Data truth must strictly reflect the local disk state. Dropped artificial padding; the UI dropdown now accurately mirrors only the physical files on disk.
|
||||
(数据必须绝对忠于本地磁盘。删除了所有的硬编码注入,确保下拉菜单严格反映本地真实的生态。)
|
||||
|
||||
## 3. UI Strobe Effects on Grid Hovers (网格悬浮的“光污染”)
|
||||
**The Problem (问题)**: Standard `transition: all 0.2s` meant quickly sweeping the mouse across the model grid caused all cards to rapidly light up like a strobe light.
|
||||
(为卡片设置了标准的 0.2秒 悬浮动画,导致鼠标快速扫过网格时,所有卡片像迪厅频闪一样疯狂亮起,显得非常廉价。)
|
||||
**The Solution (方案)**: Introduced a `transition-delay: 0.08s` low-pass filter. The recommended UX range for hover intent is 50ms - 100ms. This 80ms delay is imperceptible for intentional clicks but completely eliminates flashes from accidental sweeps.
|
||||
(引入了 `0.08s` 动画延迟作为低通滤波器。根据人机交互体验,区分“无意滑过”与“有意悬停”的黄金推荐区间是 50ms - 100ms。我们采用的 80ms 延迟对正常点击毫无察觉,但完美过滤了鼠标无意扫过造成的闪烁。)
|
||||
|
||||
## 4. The ComfyUI Manager Auto-Update File Wipe (Manager自动更新导致文件丢失)
|
||||
**The Problem (问题)**: A custom documentation file (`docs/核心按钮操作指南_大白话版.md`) generated by AI locally was silently deleted when clicking "Update" on the node inside ComfyUI Manager.
|
||||
(本地让 AI 写的说明文件,在点击 ComfyUI Manager 的更新按钮后离奇失踪了。)
|
||||
**The Solution (方案)**: ComfyUI Manager's update mechanism often performs a raw `git pull` or hard reset. If a local file is not tracked in the remote GitHub repository (i.e., you haven't committed and pushed it), it is highly likely to be overwritten or wiped during the sync process. Always commit and push your documentation, or keep personal untracked notes outside the extension directory!
|
||||
(ComfyUI Manager 的更新机制依赖 Git 同步(类似 `git reset --hard` 或强制 pull)。如果你在本地创建了文件却没有提交并推送到 GitHub,更新时 Manager 会强行把本地状态与云端对齐,导致这些“未追踪(untracked)”的文件被直接抹杀。教训:重要的文档一定要及时 push,或者别放在插件目录下!)
|
||||
|
||||
## 4. "Hostage" Inline Confirmations (被“绑架”的内联确认)
|
||||
**The Problem (问题)**: Clicking "Delete" changed the button to "Are you sure?" with a 3-second timeout, but lacked a "Cancel" button. Users misclicking were held hostage for 3 seconds.
|
||||
(点击删除后会变成“确定删除吗?”并锁定 3 秒,但没有“取消”按钮。用户如果不小心点错,只能像被绑架一样干等 3 秒它恢复。)
|
||||
**The Solution (方案)**: Wrapped the logic to instantly reveal a secondary `[✕]` cancel button upon the first click, giving control back immediately.
|
||||
(在点击删除时,立马在旁边弹出 `[✕]` 取消按钮。点击取消直接清空倒计时,立刻把控制权还给用户。)
|
||||
|
||||
## 5. First-load "Dead Interface" Avoidance (首次加载的“白屏假死”)
|
||||
**The Problem (问题)**: Initializing the sidebar to the root directory `/` presented a blank screen if users only had models nested deep in subfolders.
|
||||
(插件默认打开根目录 `/`。如果用户的模型全都在很深的子文件夹里,打开插件就会看到一片空白,以为插件坏了。)
|
||||
**The Solution (方案)**: Implemented `firstLoadDone` to automatically force the UI to open the very first directory possessing `model_count > 0`.
|
||||
(加入了智能寻路逻辑,插件首次加载时自动扫描目录树,直接定位并打开第一个存有模型的文件夹。)
|
||||
|
||||
## 6. Structural Layout Breakages (流式布局的坍塌)
|
||||
**The Problem (问题)**: Placing varying lengths of translation tags sequentially caused extreme layout jitter when hovering.
|
||||
(把中英文标签简单并排放在一起,由于字符串长度不一,鼠标悬浮时极容易发生换行挤压,导致整个界面疯狂跳动。)
|
||||
**The Solution (方案)**: Enforced a strict "Row-based" flex architecture (`align-items: stretch`). Each tag pair is locked into a geometrical row.
|
||||
(采用极其严格的行级 Flex 布局,把中英文双语硬性锁死在同一个拉伸行内,无论字数多少,几何结构绝对稳定。)
|
||||
|
||||
## 7. Modal State Persistence (弹窗状态的幽灵)
|
||||
**The Problem (问题)**: Closing and reopening the Notebook modal showed a blank editor because the JS object cached `this.currentNotebook` but the DOM was obliterated.
|
||||
(关掉笔记本弹窗再打开,右侧编辑器全空。因为 JS 内存里还记着上一次的状态,但 DOM 节点已经被销毁重建了。)
|
||||
**The Solution (方案)**: Explicitly check cached state during modal initialization and force `renderNotebookEditor()` if necessary.
|
||||
(在弹窗初始化时主动校验缓存,强制触发重新渲染,实现了完美的“会话级记忆”。)
|
||||
|
||||
## 8. Missing Visual Feedback for Background Operations (无反馈的幽灵进程)
|
||||
**The Problem (问题)**: The Notebook "Save" button executed a background `fetch` perfectly, but provided absolutely no visual feedback. Users assumed it was broken.
|
||||
(点击“保存”按钮后,后台默默完成了请求,但按钮本身没有任何变化。用户以为没点上,引发数据丢失的焦虑。)
|
||||
**The Solution (方案)**: Clicking Save instantly changes the button to a green `✅` state for 1.5 seconds.
|
||||
(点击保存后强制变绿 `✅` 1.5 秒,给用户极强的心理安全感。)
|
||||
|
||||
## 9. Spatial Geometry for Multi-Node Spawning (多节点生成的空间重叠)
|
||||
**The Problem (问题)**: Spawning an entire Notebook (Main Model + Loras + CLIPs) caused all nodes to clump perfectly at `(0,0)`.
|
||||
(一键生成整个笔记本(主模型+N个Lora+提示词)到画布时,所有节点全叠在同一个坐标上,变成了一坨。)
|
||||
**The Solution (方案)**: Implemented a "Relative Offset Matrix Layout" (`X + 350*N`, `Y + 250`) and bound it to the `mousemove` event (Magnetic Sticking).
|
||||
(加入了偏移矩阵计算,并绑定鼠标移动。用户可以直观地拖着排成流水的节点序列在画布上移动,然后再点击放下。)
|
||||
|
||||
## 10. Localization Host-Environment Constraints (寄生环境的本地化陷阱)
|
||||
**The Problem (问题)**: Reading `document.documentElement.lang` failed because ComfyUI forces the host HTML to `en-US`, breaking our Chinese translations.
|
||||
(插件试图读取网页根节点的 lang 属性来决定语言。但 ComfyUI 强行把底座设成了纯英文,导致中国用户永远看不到中文提示。)
|
||||
**The Solution (方案)**: Never trust the host document properties. Rerouted localization strictly to our internal `currentLang` variable.
|
||||
(永远不要相信宿主环境。彻底切断外部依赖,纯靠插件内部自己的 `currentLang` 变量来统治中英文环境。)
|
||||
|
||||
## 11. Strict Path Separator Validation (路径分隔符的玄学崩溃)
|
||||
**The Problem (问题)**: Windows uses backslashes (`\`), but assigning `anime/model` to a node's combo widget failed ComfyUI's strict string matching, causing red nodes.
|
||||
(Windows 的底层模型路径带反斜杠,但前端组件要求精确匹配字符串,哪怕是一个斜杠方向不对,节点也会标红报错。)
|
||||
**The Solution (方案)**: Built `setWidgetValuePath` to normalize both target paths and native options with `.replace(/\\/g, '/')` before assignment.
|
||||
(做了一个拦截器,强行把系统内的选项和我们要赋的值全部统一转成正斜杠再进行比较,直接绕过操作系统的路径陷阱。)
|
||||
|
||||
## 12. Backend Starvation During Inference (算力挤兑导致的接口饿死)
|
||||
**The Problem (问题)**: Fetching plugin data while ComfyUI is generating an image causes timeouts because of PyTorch GIL-locking.
|
||||
(在 ComfyUI 画图满载的时候,点插件会导致接口超时。因为单线程的 Python 完全被 GPU 推理抢占了。)
|
||||
**The Solution (方案)**: Accept it gracefully. Database management and intense GPU inference are mutually exclusive workflows.
|
||||
(优雅地接受这个现实。不需要过度工程化地去做离线队列,画图和管理模型本来就不该同时进行。)
|
||||
|
||||
## 13. DOM Obliteration vs Virtual DOM Hoarding (物理毁灭 vs 虚拟回收)
|
||||
**The Problem (问题)**: Maintaining performance when navigating folders containing thousands of models.
|
||||
(当一个文件夹里有上千个模型时,频繁切换文件夹会导致浏览器内存爆炸。)
|
||||
**The Solution (方案)**: Instead of `display: none` hoarding, strictly enforce `this.grid.innerHTML = ''` to physically obliterate nodes and trigger immediate Garbage Collection.
|
||||
(放弃“隐藏重用”的传统思路。切文件夹时直接暴力清空内部的 HTML 节点,强迫浏览器立马回收内存,保持零负担。)
|
||||
|
||||
## 14. The "Missing Plugin Icon" Trap (LocalStorage 坐标越界飞天)
|
||||
**The Problem (问题)**: If the floating trigger button was saved at `X=3000px` on a 4K monitor, loading it on a `1920px` laptop rendered it permanently off-screen.
|
||||
(在大显示器上把插件按钮拖到了最右边,换到小笔记本上时,坐标超出了屏幕,按钮彻底消失,再也点不出来了。)
|
||||
**The Solution (方案)**: Implemented a bounds clamp checking coordinates against `window.innerWidth`. If out of bounds, snap it to the bottom-right corner.
|
||||
(在启动时强制对比当前屏幕大小,一旦发现越界,立刻把它抓回到右下角的安全区内。)
|
||||
|
||||
## 15. The `[vite:preloadError]` Template Literal Crash (一个反引号引发的血案)
|
||||
**The Problem (问题)**: A missing backtick in a dynamic ES Module dictionary crashed the entire ComfyUI extension loader before execution.
|
||||
(在汉化字典里不小心删掉了一个反引号,导致 Vite 打包工具报语法错误,整个插件直接在加载前暴毙,UI 按钮全都没了。)
|
||||
**The Solution (方案)**: JS zero-tolerance for syntax errors requires extreme validation. Fixed the missing backtick.
|
||||
(原生 JS 没有任何编译容错,任何语法拼写都必须如履薄冰,及时修复了那个反引号。)
|
||||
|
||||
## 16. Target Size Deduplication Bug (重叠目录的体积匹配悖论)
|
||||
**The Problem (问题)**: Fixing models by file size required finding *exactly one* matching file. But users mapping `checkpoints` and `diffusion_models` to the same folder caused `os.walk` to find the same physical file twice, aborting the fix.
|
||||
(物理寻址兜底算法要求“体积必须唯一”。但用户配置了多个虚拟路径指向同一个物理文件夹,导致同一个文件被扫出两次,系统误判为“体积不唯一”而放弃修复。)
|
||||
**The Solution (方案)**: Added `os.path.realpath` deduplication matrix to condense multiple virtual paths back to a single hard drive sector.
|
||||
(引入了绝对物理地址 `realpath` 去重机制,无论虚拟路径怎么重叠,物理层面上都将其归一化。)
|
||||
|
||||
## 17. The "Lightweight Scan" Re-Hash Catastrophe (轻量扫描的重算灾难)
|
||||
**The Problem (问题)**: The lightweight scan (`--skip-media`) demanded *both* an `.info` file and a preview image to skip hashing. Since it didn't download images, it forcefully re-hashed hundreds of GBs of models every time.
|
||||
(轻量化极速扫描因为不会下载图片,导致“是否跳过计算”的判断条件(既有 info 又有图)永远不成立。引发了灾难性的全盘重新计算 SHA256,慢如蜗牛。)
|
||||
**The Solution (方案)**: Modified skip logic conditionally: if explicitly skipping media, the existence of `.info` is sufficient to bypass hashing.
|
||||
(加入了情境判断:只要是在极速模式下,不管有没有图,只要有文字信息就无条件跳过,恢复了真正的极速。)
|
||||
|
||||
## 18. The Stale Dropdown Visual Residue (顽固的红框残留)
|
||||
**The Problem (问题)**: Even after a model path was auto-fixed, ComfyUI maintained a permanent red error border on the node if the frontend cache was stale.
|
||||
(底层其实已经修复并加载了正确的模型,但因为前端选项缓存没刷过来,ComfyUI 死死把节点标红,看着十分闹心。)
|
||||
**The Solution (方案)**: Forcefully execute `delete node.color; delete node.bgcolor;` to violently strip native error states without requiring a refresh.
|
||||
(既然修好了,就直接暴力删除节点的 `color` 属性,强行洗白红框,无需刷新网页。)
|
||||
|
||||
## 19. The Silent Subprocess Death (DEVNULL 黑洞)
|
||||
**The Problem (问题)**: `scraper.py` crashed due to Python indentation, but piping `stderr` to `DEVNULL` made it totally invisible. The UI waited forever.
|
||||
(后台脚本缩进写错了导致崩溃,但代码里把报错信息塞进了无尽深渊 `DEVNULL`,没有任何提示,前端界面只能干等着,陷入死局。)
|
||||
**The Solution (方案)**: Never pipe `stderr` to DEVNULL during active development. Let it log.
|
||||
(开发阶段绝对不要屏蔽底层报错,必须让 `stderr` 打印到控制台以便排查。)
|
||||
|
||||
## 20. Tiered Fallback Resolution for O(1) Scans (多级降级寻址打造 O(1) 扫描)
|
||||
**The Problem (问题)**: Unscanned 10GB models required full disk read to calculate SHA256, taking minutes.
|
||||
(即便是第一次扫描,遇到几十 GB 的新模型,完整计算 SHA256 也要几分钟,用户体验极度卡顿。)
|
||||
**The Solution (方案)**:
|
||||
1. 0ms: Read existing `.info`. (直接读取已有的 info)
|
||||
2. 0.01s: Extract 64KB JSON header from `.safetensors` via `struct` for pre-computed hash. (用 struct 撬开文件头瞬间提取内置的 Hash,降维打击)
|
||||
3. Fallback: Full SHA256 only for legacy models. (真找不到了才迫不得已做全量计算)
|
||||
|
||||
## 21. Zero-API Tensor Fingerprinting (零API脱机张量基因识别)
|
||||
**The Problem (问题)**: HuggingFace or private models returned 404 from Civitai, failing to generate `.info` and completely breaking UI integration.
|
||||
(从 HF 拿到的或者是自己练的私模,由于 C站 没有记录,API 直接报错 404,导致模型彻底被忽略,前端里连看都看不到。)
|
||||
**The Solution (方案)**: Read the first 500 Tensor Keys via struct. Identifying `double_blocks.0.img_attn` strictly confirms Flux.1 D architecture completely offline.
|
||||
(不管你有没有网,强行读取模型前 500 层的网络结构。只要看到里面有 `double_blocks`,直接盖章确认为 Flux 架构。完全脱机,无缝融入前端兼容雷达。)
|
||||
|
||||
## 22. Gemini-Style Popover UI (告别全屏遮罩,采用悬浮弹窗)
|
||||
**The Problem (问题)**: Center-screen settings modals with blur backgrounds felt heavy and disconnected users from the workspace.
|
||||
(原本的设置面板是个带有大面积模糊遮罩的全屏居中弹窗,显得特别笨重,像个垃圾网页游戏。)
|
||||
**The Solution (方案)**: Refactored to a lightweight side-popover (`left: 100%; bottom: 15px;`) imitating Gemini's web UI, maintaining deep workflow immersion.
|
||||
(改成绝对定位,像 Gemini 网页端那样从侧边栏右侧悄悄弹出一个悬浮气泡,点外侧自动收起,极大保证了沉浸感。)
|
||||
|
||||
## 23. Dock & Mode Lock (全宽底座与多状态锁死)
|
||||
**The Problem (问题)**: Floating capsules looked cramped, and entering fullscreen Gallery while the Sidebar menu remained clickable caused DOM rendering conflicts.
|
||||
(底部按钮挤在一起极其难看。而且进入全屏图库的时候,居然还能点左上角的汉堡菜单,导致页面状态直接崩溃交织在一起。)
|
||||
**The Solution (方案)**: Extended to a 100% width solid Dock. Disabled sidebar triggers during fullscreen views via strict State Locks.
|
||||
(把底部做成了满宽的实体底座,稳固了视觉重心。一旦进入图库这种排他性的全视图,强行给菜单上互斥锁,彻底断绝乱按引发的 Bug。)
|
||||
|
||||
## 24. Resolving "Missing" vs "Unscanned" Conflicts (防重叠逻辑拦截自相踩踏)
|
||||
**The Problem (问题)**: Deleting a model and reloading the workflow popped BOTH the "Missing Model" dark modal and the "Unscanned Model" orange toast.
|
||||
(把一个模型删掉后重新加载图片,系统一方面觉得“模型丢了”弹出黑框,一方面因为缓存里没它又觉得它是“没扫过的新模型”弹出橙黄色的警告,两个弹窗叠在一块极其弱智。)
|
||||
**The Solution (方案)**: The unscanned detector now ignores nodes already flagged with red error colors (`node.color === "#FF3333"`), preventing system overlapping.
|
||||
(加入了双轨防踩踏:未扫描检测器只要看到这个节点已经红了(彻底丢了),就主动闭嘴放弃拦截,边界极其清晰。)
|
||||
|
||||
## 25. The CSS `zoom` Devastation (Zoom 属性对坐标系的降维打击)
|
||||
**The Problem (问题)**: Using `zoom` to scale the UI broke bounding boxes. `container.offsetHeight > window.innerHeight` caused `Y` coordinates to become negative, permanently launching the window off-screen into the void.
|
||||
(早期为了偷懒,用 CSS `zoom` 属性来做 UI 缩放。结果它彻底破坏了浏览器的物理坐标系,一旦窗口过大,防越界系统就算出了个负数坐标,导致插件整个闪现飞出屏幕外,再也拖不回来了。)
|
||||
**The Solution (方案)**: Banned `zoom`. Transitioned to relative scaling via `font-size: calc(16px * var(--anomalous-scale))`. Fixed bounding clamps to execute `< 0` checks last.
|
||||
(彻底封杀 zoom。改用 `calc(16px * scale)` 的底层字体相对推算放大法。并且把防负数的保底检测代码调到了逻辑的最后一行,永远杜绝负数坐标。)
|
||||
|
||||
## 26. Vanilla JS "Fake" i18n Failure (原生状态管理漏斗下的假性汉化失效)
|
||||
**The Problem (问题)**: Newly added permanent buttons ("UI Scale", "Reset") remained in Chinese when English was toggled. Since we lacked React/Vue two-way binding, they missed the global state update and got stuck.
|
||||
(新加了“UI缩放”等按钮后,即使点了切换英文,它们还是中文。因为我们没有使用 Vue 的数据绑定,纯靠原生 JS。如果新加了常驻按钮,却忘了把它登记到顶层 `langBtn.onclick` 突变循环里,它就永远被隔离在旧时间线里了。)
|
||||
(只要是常驻的按钮,必须强制在语言切换中枢里注册重写一遍。同时开发过程中如果遇到“代码没变”的灵异现象,第一反应永远是按 Ctrl+F5 强制破除浏览器静态缓存。)
|
||||
|
||||
## 27. The Image Cropping Dilemma (图库的裁切与留白博弈)
|
||||
**The Problem (问题)**: Using `object-fit: cover` in the image gallery created perfectly uniform grid squares, but brutally decapitated tall images (like 9:16 vertical character portraits).
|
||||
(在图库排版时,为了让网格看起来绝对整齐,使用了 `object-fit: cover`。结果导致偏长的图片(如 9:16 的人物竖图)顶部和底部被无情裁掉,经常出现“砍头”现象。)
|
||||
**The Solution (方案)**: Changed to `object-fit: contain` and paired it with a deep black background (`#000`). The images now scale down to fit entirely, and the resulting letterboxing seamlessly blends into the dark UI as a natural frame.
|
||||
(改为 `object-fit: contain`,完整显示图片。由于底层卡片背景本身就是极黑(`#000`),缩小后产生的黑边完美融入了 UI 背景,不仅没有任何违和感,反而像是一圈高级的画框,一举两得。)
|
||||
|
||||
## 28. Vanilla JS Global Click-Outside (原生悬浮面板的全局点击收起)
|
||||
**The Problem (问题)**: The settings popover only closed if the user explicitly clicked the gear icon again. Clicking elsewhere on the canvas did nothing, making the popover feel rigid and intrusive.
|
||||
(点击齿轮打开设置悬浮气泡后,必须再点一次齿轮才能关掉。如果随手点一下旁边或者点画布,面板居然不收回,交互显得非常死板和占眼。)
|
||||
**The Solution (方案)**: Attached a temporary `mousedown` event listener to the `document` when the popover opens. If the click target is outside the modal, it triggers `display: none` and instantly `removeEventListener` to prevent memory leaks.
|
||||
(面板弹出时,立刻给全局 `document` 挂载一个监听器。只要检测到鼠标点击了面板外部区域,立刻收缩面板,并且**顺手注销掉这个监听器**,实现了极度顺滑的 Gemini 式收放体验且毫无性能负担。)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""File SHA-256 evidence shared by the standalone scanner and API."""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
def normalise_sha256(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", value.strip()):
|
||||
return ""
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
def computed_file_identity(path, digest):
|
||||
digest = normalise_sha256(digest)
|
||||
if not digest:
|
||||
raise ValueError("Invalid file SHA-256")
|
||||
stat = os.stat(path)
|
||||
return {
|
||||
"algorithm": "sha256", "scope": "file", "value": digest,
|
||||
"size": stat.st_size, "mtime_ns": stat.st_mtime_ns, "source": "computed",
|
||||
}
|
||||
|
||||
|
||||
def sidecar_file_hash(data, path, selected_file):
|
||||
"""Keep cached file evidence separate from unverified embedded model hashes."""
|
||||
identity = data.get("anomalous_file_identity")
|
||||
if identity is not None:
|
||||
if not isinstance(identity, dict) or identity.get("algorithm") != "sha256" or identity.get("scope") != "file":
|
||||
return "", ""
|
||||
stat = os.stat(path)
|
||||
if identity.get("size") != stat.st_size or identity.get("mtime_ns") != stat.st_mtime_ns:
|
||||
return "", ""
|
||||
return normalise_sha256(identity.get("value")), "computed file SHA-256"
|
||||
# Older offline records could label a BLAKE3/tensor digest as SHA-256.
|
||||
# Keep their presentation data, but require a scan or on-demand verification.
|
||||
if data.get("id") == -1 or data.get("modelId") == -1:
|
||||
return "", ""
|
||||
hashes = selected_file.get("hashes", {}) if isinstance(selected_file, dict) else {}
|
||||
return normalise_sha256(hashes.get("SHA256")), "sidecar file SHA-256"
|
||||
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# These components are normally referenced by stable ecosystem filenames.
|
||||
# Renaming them breaks existing workflows without providing meaningful value.
|
||||
PHYSICAL_RENAME_PROTECTED_TYPES = frozenset({
|
||||
"vae",
|
||||
"vae_approx",
|
||||
"clip",
|
||||
"text_encoders",
|
||||
"clip_vision",
|
||||
})
|
||||
|
||||
# This is a recovery-confidence policy, not a scanner-support list. The scan
|
||||
# wizard may hash these folders, but Model Doctor must not redirect one of these
|
||||
# components from size, filename, metadata labels, or Civitai availability.
|
||||
HASH_ONLY_RECOVERY_TYPES = frozenset({
|
||||
"vae",
|
||||
"vae_approx",
|
||||
"clip",
|
||||
"text_encoders",
|
||||
"clip_vision",
|
||||
})
|
||||
|
||||
|
||||
def is_physical_rename_protected(folder_type=None, folder_path=None):
|
||||
"""Return True when a model category must keep its physical filename."""
|
||||
normalized_type = str(folder_type or "").strip().lower()
|
||||
if normalized_type in PHYSICAL_RENAME_PROTECTED_TYPES:
|
||||
return True
|
||||
|
||||
if not folder_path:
|
||||
return False
|
||||
|
||||
try:
|
||||
parts = {
|
||||
os.path.normcase(part).lower()
|
||||
for part in Path(os.path.abspath(folder_path)).parts
|
||||
}
|
||||
except (OSError, TypeError, ValueError):
|
||||
return False
|
||||
return bool(parts.intersection(PHYSICAL_RENAME_PROTECTED_TYPES))
|
||||
|
||||
|
||||
def requires_hash_for_model_recovery(folder_types):
|
||||
"""Return True when every requested category requires a hash match."""
|
||||
normalized = {
|
||||
str(folder_type or "").strip().lower()
|
||||
for folder_type in (folder_types or ())
|
||||
if str(folder_type or "").strip()
|
||||
}
|
||||
return bool(normalized) and normalized.issubset(HASH_ONLY_RECOVERY_TYPES)
|
||||
@@ -1,6 +1,9 @@
|
||||
import os
|
||||
import sys
|
||||
try:
|
||||
import os
|
||||
import sys
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if _SCRIPT_DIR not in sys.path:
|
||||
sys.path.insert(0, _SCRIPT_DIR)
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except:
|
||||
pass
|
||||
@@ -12,27 +15,84 @@ import time
|
||||
import json
|
||||
import hashlib
|
||||
import re
|
||||
import requests
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import argparse
|
||||
import shutil
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, Optional
|
||||
from model_policies import is_physical_rename_protected
|
||||
from model_identity import computed_file_identity
|
||||
|
||||
|
||||
# Fixed tuples avoid rebuilding long extension lists for every scanned model.
|
||||
# Exact-path checks are intentionally used instead of directory-wide globbing.
|
||||
MEDIA_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi")
|
||||
PREVIEW_SUFFIXES = tuple(f".preview{ext}" for ext in MEDIA_EXTENSIONS)
|
||||
CIVITAI_BACKUP_SUFFIXES = tuple(f".civitai_bak{ext}" for ext in MEDIA_EXTENSIONS)
|
||||
COVER_SUFFIXES = MEDIA_EXTENSIONS + PREVIEW_SUFFIXES
|
||||
ACTIVE_COVER_SUFFIXES = PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
|
||||
SIDECAR_SUFFIXES = (
|
||||
".info", ".civitai.info", ".json", ".txt", ".yaml",
|
||||
*MEDIA_EXTENSIONS,
|
||||
*PREVIEW_SUFFIXES,
|
||||
*CIVITAI_BACKUP_SUFFIXES,
|
||||
)
|
||||
|
||||
|
||||
def write_scan_progress(progress_file, phase, total=0, current=0, filename=""):
|
||||
if not progress_file:
|
||||
return
|
||||
temp_file = f"{progress_file}.{os.getpid()}.tmp"
|
||||
try:
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"phase": phase,
|
||||
"total": total,
|
||||
"current": current,
|
||||
"filename": filename,
|
||||
}, f, ensure_ascii=False)
|
||||
os.replace(temp_file, progress_file)
|
||||
except OSError:
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def count_scan_files(target_folder, target_files_basenames):
|
||||
total = 0
|
||||
selected = set(target_files_basenames)
|
||||
for _, _, files in os.walk(target_folder):
|
||||
total += sum(
|
||||
1 for filename in files
|
||||
if filename.endswith(".safetensors") and (not selected or filename in selected)
|
||||
)
|
||||
return total
|
||||
|
||||
# ==============================================================================
|
||||
# CIVITAI API 配置读取
|
||||
# 请在插件目录 (Anomalous_Model_Browser) 下新建 config.json 文件:
|
||||
# { "CIVITAI_API_KEY": "你的KEY" }
|
||||
# ==============================================================================
|
||||
CIVITAI_API_KEY = None
|
||||
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
CIVITAI_API_KEY = cfg.get("CIVITAI_API_KEY", "").strip()
|
||||
except Exception as e:
|
||||
print(f"[-] 读取 config.json 失败: {e}")
|
||||
else:
|
||||
print("[!] 未找到 config.json,本次请求将不使用 API Key。部分限制级模型或将无法获取图片。")
|
||||
CIVITAI_API_KEY = None
|
||||
plugin_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_paths = [os.path.join(plugin_dir, "api", "config.json"), os.path.join(plugin_dir, "config.json")]
|
||||
for config_path in config_paths:
|
||||
if not os.path.exists(config_path):
|
||||
continue
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
configured_key = cfg.get("CIVITAI_API_KEY", "")
|
||||
if isinstance(configured_key, str) and configured_key.strip():
|
||||
CIVITAI_API_KEY = configured_key.strip()
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[-] 读取 config.json 失败: {e}")
|
||||
|
||||
if not CIVITAI_API_KEY:
|
||||
print("[!] 未配置 Civitai API Key。部分限制级模型或将无法获取图片。")
|
||||
|
||||
def calculate_sha256(file_path: str) -> str:
|
||||
"""计算文件的 SHA256 哈希值 (用于 Civitai 匹配)"""
|
||||
@@ -45,40 +105,6 @@ def calculate_sha256(file_path: str) -> str:
|
||||
|
||||
import struct
|
||||
|
||||
def extract_safetensors_hash(file_path: str) -> Optional[str]:
|
||||
"""尝试从 safetensors 头文件中以 O(1) 速度提取内置的 Hash,跳过全量计算"""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
header_size_bytes = f.read(8)
|
||||
if len(header_size_bytes) < 8:
|
||||
return None
|
||||
header_size = struct.unpack('<Q', header_size_bytes)[0]
|
||||
if header_size > 100 * 1024 * 1024: # 异常大小保护 (头文件大于100MB)
|
||||
return None
|
||||
|
||||
header_json_bytes = f.read(header_size)
|
||||
header_str = header_json_bytes.decode('utf-8')
|
||||
header_json = json.loads(header_str)
|
||||
|
||||
metadata = header_json.get('__metadata__', {})
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
# 优先级1: 标准 modelspec
|
||||
if 'modelspec.hash.sha256' in metadata:
|
||||
return metadata['modelspec.hash.sha256']
|
||||
if 'modelspec.hash.blake3' in metadata:
|
||||
return metadata['modelspec.hash.blake3']
|
||||
|
||||
# 优先级2: 其他带 hash 的键
|
||||
for k, v in metadata.items():
|
||||
if ('hash' in k.lower() or 'civitai' in k.lower()) and isinstance(v, str):
|
||||
if len(v) == 64 and all(c in '0123456789abcdefABCDEF' for c in v):
|
||||
return v
|
||||
except Exception as e:
|
||||
pass
|
||||
return None
|
||||
|
||||
def infer_base_model_from_header(file_path: str) -> str:
|
||||
"""从 safetensors 头文件的张量键名推断底层 Base Model (用于脱机/HuggingFace 兼容)"""
|
||||
try:
|
||||
@@ -133,16 +159,18 @@ def fetch_civitai_info(file_hash: str, max_retries: int = 3) -> Optional[Dict]:
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=15)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
elif response.status_code == 404:
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=15) as response:
|
||||
return json.loads(response.read().decode('utf-8'))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
print(f"\033[93m[Skip] 模型 Hash {file_hash} 未在 Civitai 找到 (404),已跳过。\033[0m")
|
||||
return None
|
||||
else:
|
||||
print(f"[-] 请求异常,状态码: {response.status_code} (尝试 {attempt+1}/{max_retries})")
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[-] 网络请求超时或异常: {e} (尝试 {attempt+1}/{max_retries})")
|
||||
print(f"[-] 请求异常,状态码: {e.code} (尝试 {attempt+1}/{max_retries})")
|
||||
except urllib.error.URLError as e:
|
||||
print(f"[-] 网络请求超时或异常: {e.reason} (尝试 {attempt+1}/{max_retries})")
|
||||
except Exception as e:
|
||||
print(f"[-] 未知异常: {e} (尝试 {attempt+1}/{max_retries})")
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2)
|
||||
@@ -160,8 +188,8 @@ def download_media(url: str, base_path: str, max_retries: int = 3):
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = requests.get(url, stream=True, headers=headers, timeout=15)
|
||||
if response.status_code == 200:
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=15) as response:
|
||||
content_type = response.headers.get("Content-Type", "").lower()
|
||||
ext = ".png" # default
|
||||
if "video/mp4" in content_type: ext = ".mp4"
|
||||
@@ -172,13 +200,18 @@ def download_media(url: str, base_path: str, max_retries: int = 3):
|
||||
|
||||
final_path = base_path + ext
|
||||
with open(final_path, 'wb') as f:
|
||||
for chunk in response.iter_content(8192):
|
||||
while True:
|
||||
chunk = response.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
return final_path
|
||||
else:
|
||||
print(f"[-] 媒体下载失败,状态码: {response.status_code} (尝试 {attempt+1}/{max_retries})")
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[-] 媒体下载网络异常: {e} (尝试 {attempt+1}/{max_retries})")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"[-] 媒体下载失败,状态码: {e.code} (尝试 {attempt+1}/{max_retries})")
|
||||
except urllib.error.URLError as e:
|
||||
print(f"[-] 媒体下载网络异常: {e.reason} (尝试 {attempt+1}/{max_retries})")
|
||||
except Exception as e:
|
||||
pass
|
||||
return None
|
||||
|
||||
def main():
|
||||
@@ -187,13 +220,28 @@ def main():
|
||||
parser.add_argument("--dry-run", action="store_true", help="空跑模式,仅打印将要执行的操作,不修改任何文件")
|
||||
parser.add_argument("--undo", action="store_true", help="根据 backup_rename_log.json 恢复文件名")
|
||||
parser.add_argument("--skip-rename", action="store_true", help="只下载信息文件,不重命名主文件")
|
||||
parser.add_argument("--virtual-rename", action="store_true", help="虚拟重命名:修改 JSON 注入标准名称,不修改底层物理文件名")
|
||||
parser.add_argument("--physical-rename", action="store_true", help="物理重命名:真实修改底层的 safetensors 及其附属文件名")
|
||||
parser.add_argument("--skip-media", action="store_true", help="不下载预览图或视频")
|
||||
args = parser.parse_args()
|
||||
parser.add_argument("--offline-only", action="store_true", help="跳过 Civitai 联网获取,强制使用本地脱机张量推断提取 Base Model")
|
||||
parser.add_argument("--force-overwrite", action="store_true", help="强制覆盖已存在的信息文件")
|
||||
parser.add_argument("--skip-local-metadata", action="store_true", help="忽略本地已有的.info / .json文件")
|
||||
parser.add_argument("--target-files", type=str, default="", help="仅扫描逗号分隔的具体文件(相对路径)")
|
||||
parser.add_argument("--folder-type", default="", help="由 ComfyUI 传入的模型目录类型,用于执行安全策略")
|
||||
parser.add_argument("--progress-file", default="", help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
if args.offline_only:
|
||||
# Offline means no network at all: previews and model pages come from Civitai.
|
||||
args.skip_media = True
|
||||
|
||||
target_folder = args.folder
|
||||
if not os.path.isdir(target_folder):
|
||||
print(f"[-] 错误: 文件夹不存在 -> {target_folder}")
|
||||
sys.exit(1)
|
||||
if not os.path.isdir(target_folder):
|
||||
print(f"[-] 错误: 文件夹不存在 -> {target_folder}")
|
||||
sys.exit(1)
|
||||
|
||||
if args.physical_rename and is_physical_rename_protected(args.folder_type, target_folder):
|
||||
print("[*] 基础组件目录受保护:VAE、CLIP/Text Encoder 与 CLIP Vision 不执行物理重命名。")
|
||||
args.physical_rename = False
|
||||
|
||||
backup_log_path = os.path.join(target_folder, "backup_rename_log.json")
|
||||
|
||||
@@ -220,7 +268,7 @@ def main():
|
||||
old_base = os.path.splitext(old_path)[0]
|
||||
new_base = os.path.splitext(new_path)[0]
|
||||
|
||||
for ext in [".info", ".civitai.info", ".png", ".jpg", ".webp", ".mp4", ".webm", ".json", ".txt", ".yaml"]:
|
||||
for ext in SIDECAR_SUFFIXES:
|
||||
new_ext_path = new_base + ext
|
||||
old_ext_path = old_base + ext
|
||||
if os.path.exists(new_ext_path):
|
||||
@@ -241,79 +289,131 @@ def main():
|
||||
with open(backup_log_path, 'r', encoding='utf-8') as f:
|
||||
rename_log = json.load(f)
|
||||
|
||||
print(f"[*] 开始扫描文件夹: {target_folder}")
|
||||
target_files_basenames = []
|
||||
target_file_path = os.path.join(target_folder, '.scan_targets.json')
|
||||
if os.path.exists(target_file_path):
|
||||
try:
|
||||
with open(target_file_path, 'r', encoding='utf-8') as f:
|
||||
target_files_basenames = [os.path.basename(t.strip()) for t in __import__('json').load(f)]
|
||||
os.remove(target_file_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
if args.target_files:
|
||||
target_files_basenames.extend([os.path.basename(t.strip()) for t in args.target_files.split(',')])
|
||||
|
||||
print(f"[*] 开始扫描文件夹: {target_folder}")
|
||||
if args.dry_run:
|
||||
print("==================================================")
|
||||
print("[警告]: 当前处于 Dry-Run (空跑) 模式,不会修改系统中的任何文件!")
|
||||
print("==================================================")
|
||||
|
||||
for root, _, files in os.walk(target_folder):
|
||||
for filename in files:
|
||||
write_scan_progress(args.progress_file, "enumerating")
|
||||
total_files = count_scan_files(target_folder, target_files_basenames)
|
||||
write_scan_progress(args.progress_file, "scanning", total_files)
|
||||
current_file = 0
|
||||
last_progress_write = 0.0
|
||||
|
||||
for root, _, files in os.walk(target_folder):
|
||||
for filename in files:
|
||||
if not filename.endswith(".safetensors"):
|
||||
continue
|
||||
|
||||
if target_files_basenames and filename not in target_files_basenames:
|
||||
continue
|
||||
|
||||
current_file += 1
|
||||
now = time.monotonic()
|
||||
if current_file == 1 or current_file == total_files or now - last_progress_write >= 0.2:
|
||||
write_scan_progress(args.progress_file, "scanning", total_files, current_file, filename)
|
||||
last_progress_write = now
|
||||
|
||||
file_path = os.path.join(root, filename)
|
||||
old_base = os.path.splitext(file_path)[0]
|
||||
|
||||
info_exists = os.path.exists(old_base + ".info") or os.path.exists(old_base + ".civitai.info")
|
||||
if args.force_overwrite:
|
||||
info_exists = False
|
||||
|
||||
preview_exists = args.skip_media
|
||||
if not preview_exists:
|
||||
for ext in [".png", ".preview.png", ".jpg", ".jpeg", ".webp", ".mp4", ".webm"]:
|
||||
for ext in COVER_SUFFIXES:
|
||||
if os.path.exists(old_base + ext):
|
||||
preview_exists = True
|
||||
break
|
||||
|
||||
if info_exists and preview_exists:
|
||||
needs_rename = False
|
||||
if not args.skip_rename and args.physical_rename:
|
||||
needs_rename = True
|
||||
elif args.virtual_rename:
|
||||
needs_rename = True
|
||||
|
||||
if info_exists and preview_exists and not needs_rename:
|
||||
print(f"[*] 已跳过 (信息满足要求): {filename}")
|
||||
continue
|
||||
|
||||
print(f"\n---> 处理文件: {filename} (位于 {root})")
|
||||
file_hash = extract_safetensors_hash(file_path)
|
||||
if file_hash:
|
||||
print(f"[*] 成功从头文件极速提取 Hash: {file_hash}")
|
||||
else:
|
||||
file_hash = calculate_sha256(file_path)
|
||||
print(f"[*] 全量物理 SHA256: {file_hash}")
|
||||
|
||||
civitai_data = fetch_civitai_info(file_hash)
|
||||
civitai_data = None
|
||||
file_hash = None
|
||||
if info_exists and needs_rename:
|
||||
info_path = old_base + ".info"
|
||||
if not os.path.exists(info_path):
|
||||
info_path = old_base + ".civitai.info"
|
||||
try:
|
||||
with open(info_path, 'r', encoding='utf-8') as f:
|
||||
civitai_data = json.load(f)
|
||||
print(f"[*] 本地信息存在,直接进入重命名流程")
|
||||
except:
|
||||
pass
|
||||
|
||||
if not civitai_data:
|
||||
# 尝试离线推断底模
|
||||
inferred_base = infer_base_model_from_header(file_path)
|
||||
if inferred_base != 'Unknown':
|
||||
print(f"[*] 离线推断底模成功 ({filename}): {inferred_base}")
|
||||
civitai_data = {
|
||||
"id": -1,
|
||||
"modelId": -1,
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"baseModel": inferred_base,
|
||||
"description": "<p>Automatically inferred by Anomalous Local Engine.</p>",
|
||||
"model": {
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"type": "LORA" if "lora" in root.lower() else "Checkpoint"
|
||||
},
|
||||
"files": [{"hashes": {"SHA256": file_hash}}]
|
||||
}
|
||||
else:
|
||||
file_hash = calculate_sha256(file_path)
|
||||
if not args.offline_only:
|
||||
civitai_data = fetch_civitai_info(file_hash)
|
||||
|
||||
# Fallback 3: Local Offline Inference (if Civitai still fails or offline_only)
|
||||
if not civitai_data:
|
||||
if args.skip_local_metadata:
|
||||
print(f"[*] Civitai 获取失败,且禁用了本地元数据解析。跳过 {filename}")
|
||||
fail_count += 1
|
||||
continue
|
||||
# 尝试离线推断底模
|
||||
inferred_base = infer_base_model_from_header(file_path)
|
||||
if inferred_base == 'Unknown':
|
||||
inferred_base = ""
|
||||
|
||||
print(f"[*] 使用本地哈希重建基础元数据 ({filename})")
|
||||
civitai_data = {
|
||||
"id": -1,
|
||||
"modelId": -1,
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"baseModel": inferred_base,
|
||||
"description": "<p>Automatically inferred by Anomalous Local Engine.</p>",
|
||||
"model": {
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"type": "LORA" if "lora" in root.lower() else "Checkpoint"
|
||||
},
|
||||
"files": [{"hashes": {"SHA256": file_hash}}]
|
||||
}
|
||||
|
||||
# --- 额外获取模型主页的说明文字 ---
|
||||
model_id = civitai_data.get("modelId")
|
||||
if model_id and model_id != -1:
|
||||
if model_id and model_id != -1 and not args.offline_only:
|
||||
try:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
}
|
||||
if CIVITAI_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {CIVITAI_API_KEY}"
|
||||
m_resp = requests.get(f"https://civitai.com/api/v1/models/{model_id}", headers=headers, timeout=10)
|
||||
if m_resp.status_code == 200:
|
||||
m_data = m_resp.json()
|
||||
main_desc = m_data.get("description")
|
||||
if main_desc:
|
||||
req = urllib.request.Request(f"https://civitai.com/api/v1/models/{model_id}", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=10) as m_resp:
|
||||
m_data = json.loads(m_resp.read().decode('utf-8'))
|
||||
if "description" in m_data and m_data["description"]:
|
||||
civitai_data["description"] = m_data["description"]
|
||||
if "model" not in civitai_data or not isinstance(civitai_data["model"], dict):
|
||||
civitai_data["model"] = {}
|
||||
civitai_data["model"]["description"] = main_desc
|
||||
civitai_data["model"]["description"] = m_data["description"]
|
||||
except Exception as e:
|
||||
print(f"[-] 获取模型主页详细说明失败: {e}")
|
||||
# ----------------------------------
|
||||
@@ -329,7 +429,12 @@ def main():
|
||||
# 兼容性大刀阔斧改革:直接保存全宇宙最原汁原味的格式
|
||||
# ==========================================
|
||||
info_data = civitai_data
|
||||
if file_hash:
|
||||
info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
|
||||
|
||||
if args.virtual_rename:
|
||||
info_data["anomalous_custom_name"] = f"{model_name}_{version_name}"
|
||||
|
||||
if not args.dry_run:
|
||||
info_path = old_base + ".info"
|
||||
with open(info_path, 'w', encoding='utf-8') as f:
|
||||
@@ -338,10 +443,10 @@ def main():
|
||||
else:
|
||||
print(f"[Dry-Run] 拟生成标准描述信息 -> .info")
|
||||
|
||||
images = civitai_data.get("images", [])
|
||||
if images and len(images) > 0:
|
||||
media_url = None
|
||||
for img_obj in images:
|
||||
media_url = None
|
||||
images = civitai_data.get("images", [])
|
||||
if images and len(images) > 0:
|
||||
for img_obj in images:
|
||||
if not args.skip_media:
|
||||
if img_obj.get("url"):
|
||||
media_url = img_obj.get("url")
|
||||
@@ -350,51 +455,88 @@ def main():
|
||||
if media_url and not args.skip_media:
|
||||
if not args.dry_run:
|
||||
print(f"[*] 正在下载预览媒体...")
|
||||
saved_path = download_media(media_url, old_base)
|
||||
saved_path = download_media(media_url, old_base + ".civitai_bak")
|
||||
if saved_path:
|
||||
print(f"[+] 媒体下载成功 -> {os.path.basename(saved_path)}")
|
||||
# Promote to .preview if no custom cover exists
|
||||
has_custom = False
|
||||
if not args.force_overwrite:
|
||||
for c_ext in ACTIVE_COVER_SUFFIXES:
|
||||
p = old_base + c_ext
|
||||
if os.path.exists(p) and not p.endswith('.civitai_bak' + c_ext):
|
||||
has_custom = True
|
||||
break
|
||||
if not has_custom:
|
||||
ext = os.path.splitext(saved_path)[1]
|
||||
import shutil
|
||||
if args.force_overwrite:
|
||||
for c_ext in ACTIVE_COVER_SUFFIXES:
|
||||
p = old_base + c_ext
|
||||
if os.path.exists(p) and not p.endswith('.civitai_bak' + c_ext):
|
||||
try:
|
||||
os.remove(p)
|
||||
print(f"[*] 强制覆盖: 已删除旧预览文件 {os.path.basename(p)}")
|
||||
except:
|
||||
pass
|
||||
preview_ext = ext if ext.startswith('.preview.') else f".preview{ext}"
|
||||
shutil.copy2(saved_path, old_base + preview_ext)
|
||||
else:
|
||||
print(f"[Dry-Run] 拟下载预览媒体...")
|
||||
|
||||
if args.skip_rename:
|
||||
print(f"[*] --skip-rename 开启,跳过主文件重命名。仅保存 .info。")
|
||||
if args.skip_rename or not args.physical_rename:
|
||||
print(f"[*] 物理重命名已跳过。仅保存 .info 及其可能包含的虚拟重命名。")
|
||||
success_count += 1
|
||||
elif file_path != new_file_path and new_filename != filename:
|
||||
if os.path.exists(new_file_path):
|
||||
print(f"\033[91m[-] 目标版本已存在 (重复模型),执行永久删除多余副本: {filename}\033[0m")
|
||||
if not args.dry_run:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
for ext in [".info", ".civitai.info", ".png", ".jpg", ".webp", ".mp4", ".webm", ".json", ".txt", ".yaml"]:
|
||||
if os.path.exists(new_file_path):
|
||||
print(f"[*] 目标文件名已存在,正在验证内容是否完全相同: {filename}")
|
||||
try:
|
||||
files_identical = os.path.getsize(file_path) == os.path.getsize(new_file_path)
|
||||
if files_identical:
|
||||
files_identical = calculate_sha256(file_path) == calculate_sha256(new_file_path)
|
||||
except OSError:
|
||||
files_identical = False
|
||||
|
||||
if not files_identical:
|
||||
print(f"\033[91m[-] 目标名称冲突但文件内容不同,已保留两个模型并跳过重命名: {filename}\033[0m")
|
||||
fail_count += 1
|
||||
elif not args.dry_run:
|
||||
try:
|
||||
print(f"[*] Hash 一致,删除已确认的重复副本: {filename}")
|
||||
os.remove(file_path)
|
||||
for ext in SIDECAR_SUFFIXES:
|
||||
old_ext = old_base + ext
|
||||
if os.path.exists(old_ext):
|
||||
os.remove(old_ext)
|
||||
except Exception as e:
|
||||
print(f"[-] 删除多余副本失败 (可能文件被占用): {e}")
|
||||
else:
|
||||
print(f"[Dry-Run] 拟永久删除多余副本及其附属文件: {filename}")
|
||||
else:
|
||||
os.remove(old_ext)
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
print(f"[-] 删除多余副本失败 (可能文件被占用): {e}")
|
||||
fail_count += 1
|
||||
else:
|
||||
print(f"[Dry-Run] Hash 一致,拟删除重复副本及其附属文件: {filename}")
|
||||
else:
|
||||
if not args.dry_run:
|
||||
os.rename(file_path, new_file_path)
|
||||
rename_log[file_path] = new_file_path
|
||||
|
||||
for ext in [".info", ".civitai.info", ".png", ".jpg", ".webp", ".mp4", ".webm", ".json", ".txt", ".yaml"]:
|
||||
for ext in SIDECAR_SUFFIXES:
|
||||
old_ext = old_base + ext
|
||||
new_ext = new_base + ext
|
||||
if os.path.exists(old_ext):
|
||||
os.replace(old_ext, new_ext) # Safety: replace overwrites existing files on Windows
|
||||
os.replace(old_ext, new_ext)
|
||||
|
||||
print(f"[+] 重命名完成: {filename} ==> {new_filename}")
|
||||
print(f"[+] 物理重命名完成: {filename} ==> {new_filename}")
|
||||
success_count += 1
|
||||
else:
|
||||
print(f"[Dry-Run] 拟重命名文件: {filename} ==> {new_filename}")
|
||||
print(f"[Dry-Run] 拟物理重命名文件: {filename} ==> {new_filename}")
|
||||
print(f"[Dry-Run] 拟连带重命名附属文件 (.info / .png 等)")
|
||||
else:
|
||||
print("[*] 文件名已符合规范,无需重命名。")
|
||||
success_count += 1
|
||||
|
||||
|
||||
# Save scan results
|
||||
if not args.dry_run:
|
||||
# Save scan results
|
||||
write_scan_progress(args.progress_file, "complete", total_files, total_files)
|
||||
if not args.dry_run:
|
||||
result_path = os.path.join(target_folder, ".scan_result.json")
|
||||
try:
|
||||
with open(result_path, 'w', encoding='utf-8') as f:
|
||||
@@ -408,4 +550,4 @@ def main():
|
||||
print(f"\n[+] 重命名映射日志已保存至: {backup_log_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1,401 +1,421 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
// Global cache for hashes: filename -> hash
|
||||
window.anomalous_hash_cache = window.anomalous_hash_cache || {};
|
||||
|
||||
// Function to update cache, can be called from main.js
|
||||
import { translate } from './modules/locales.js';
|
||||
import { anomalousConfirm } from './modules/ui_dialog.js';
|
||||
import { findWorkflowHashRecord } from './modules/recipe_provenance.js';
|
||||
import {
|
||||
inferModelFolderTypes,
|
||||
requiresHashForModelRecovery,
|
||||
} from './modules/model_policies.js';
|
||||
|
||||
// Global cache for hashes: filename -> hash
|
||||
window.anomalous_hash_cache = window.anomalous_hash_cache || {};
|
||||
|
||||
// Function to update cache, can be called from main.js
|
||||
window.anomalous_update_hash_cache = function (models) {
|
||||
if (!models) return;
|
||||
for (const m of models) {
|
||||
if (m.filename && m.metadata && m.metadata.hash) {
|
||||
window.anomalous_hash_cache[m.filename] = {
|
||||
hash: m.metadata.hash,
|
||||
size: m.size_bytes || ""
|
||||
};
|
||||
}
|
||||
if (!models) return;
|
||||
for (const m of models) {
|
||||
if (m.filename && m.metadata && m.metadata.hash) {
|
||||
window.anomalous_hash_cache[m.filename] = {
|
||||
hash: m.metadata.hash,
|
||||
size: m.size_bytes || ""
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.anomalous_resolve_all_missing_nodes = async function (is_manual = false, force_prompt = false) {
|
||||
if (!is_manual && localStorage.getItem('anomalous_auto_scan_enabled') !== 'true') return;
|
||||
if (!app.graph || !app.graph._nodes) return;
|
||||
function isModelFilename(value) {
|
||||
return typeof value === 'string' && /\.(safetensors|ckpt|pt|bin)$/i.test(value);
|
||||
}
|
||||
|
||||
// Fast path: if there are no missing nodes and it's not a manual check, abort early to save performance
|
||||
let has_missing = false;
|
||||
function inferExpectedModelTypes(node, widget) {
|
||||
return inferModelFolderTypes(node, widget).join(',');
|
||||
}
|
||||
|
||||
window.anomalous_resolve_all_missing_nodes = async function (is_manual = false, silent = false) {
|
||||
if (!is_manual && localStorage.getItem('anomalous_auto_scan_enabled') !== 'true') return;
|
||||
if (!app.graph || !app.graph._nodes) return;
|
||||
|
||||
// Fast path: if there are no missing nodes and it's not a manual check, abort early to save performance
|
||||
let has_missing = false;
|
||||
for (const node of app.graph._nodes) {
|
||||
if (node.color === "#FF3333" || node.bgcolor === "#FF3333" || node.color === "#f66" || (node.flags && node.flags.collapsed && node.color === "#FF3333")) {
|
||||
has_missing = true;
|
||||
break;
|
||||
}
|
||||
if (node.widgets) {
|
||||
for (let i = 0; i < node.widgets.length; i++) {
|
||||
const w = node.widgets[i];
|
||||
if (isModelFilename(w.value)) {
|
||||
if (w.options && w.options.values && !w.options.values.includes(w.value)) {
|
||||
has_missing = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (has_missing) break;
|
||||
}
|
||||
|
||||
if (!has_missing && !is_manual) return;
|
||||
|
||||
// Refresh ComfyUI's frontend folder paths cache to prevent false-positive red nodes
|
||||
if (app.refreshComboInNodes) {
|
||||
try {
|
||||
await app.refreshComboInNodes();
|
||||
} catch (e) {
|
||||
console.warn("[Anomalous Hash Resolver] Failed to refresh ComfyUI node combos", e);
|
||||
}
|
||||
}
|
||||
|
||||
const hashes = (app.graph.extra && app.graph.extra.anomalous_hashes) || {};
|
||||
const getWorkflowHash = (nodeId, val) => {
|
||||
if (!hashes || typeof val !== 'string') return null;
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const winVal = val.replace(/\//g, '\\');
|
||||
return hashes[`${nodeId}_${val}`] ||
|
||||
hashes[`${nodeId}_${normVal}`] ||
|
||||
hashes[`${nodeId}_${winVal}`] ||
|
||||
hashes[val] ||
|
||||
hashes[normVal] ||
|
||||
hashes[winVal] ||
|
||||
null;
|
||||
};
|
||||
|
||||
let needsGlobalHashRefresh = false;
|
||||
provenanceCheck:
|
||||
for (const node of app.graph._nodes) {
|
||||
if (node.color === "#FF3333" || node.bgcolor === "#FF3333" || node.color === "#f66" || (node.flags && node.flags.collapsed && node.color === "#FF3333")) {
|
||||
has_missing = true;
|
||||
break;
|
||||
}
|
||||
if (node.widgets) {
|
||||
for (let i = 0; i < node.widgets.length; i++) {
|
||||
const w = node.widgets[i];
|
||||
if (typeof w.value === 'string' && (w.value.endsWith('.safetensors') || w.value.endsWith('.ckpt') || w.value.endsWith('.pt'))) {
|
||||
if (w.options && w.options.values && !w.options.values.includes(w.value)) {
|
||||
has_missing = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const widget of (node.widgets || [])) {
|
||||
const value = widget.value;
|
||||
if (!isModelFilename(value)) continue;
|
||||
if (!getWorkflowHash(node.id, value)) {
|
||||
needsGlobalHashRefresh = true;
|
||||
break provenanceCheck;
|
||||
}
|
||||
}
|
||||
if (has_missing) break;
|
||||
}
|
||||
|
||||
if (!has_missing && !is_manual) return;
|
||||
|
||||
// Refresh ComfyUI's frontend folder paths cache to prevent false-positive red nodes
|
||||
if (app.refreshComboInNodes) {
|
||||
// Provenance-rich workflows already carry the identity data needed by the
|
||||
// doctor. Refresh the full local filename->hash cache only for legacy items
|
||||
// that actually need that fallback.
|
||||
if (needsGlobalHashRefresh) {
|
||||
try {
|
||||
await app.refreshComboInNodes();
|
||||
const res = await fetch('/anomalous/all_hashes?t=' + Date.now());
|
||||
const data = await res.json();
|
||||
const hashesObj = data.hashes ? data.hashes : data;
|
||||
Object.assign(window.anomalous_hash_cache, hashesObj);
|
||||
} catch (e) {
|
||||
console.warn("[Anomalous Hash Resolver] Failed to refresh ComfyUI node combos", e);
|
||||
console.warn("[Anomalous] Failed to fetch hashes for manual resolution", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Always fetch latest hashes before trying to resolve
|
||||
try {
|
||||
const res = await fetch('/anomalous/all_hashes');
|
||||
const data = await res.json();
|
||||
Object.assign(window.anomalous_hash_cache, data);
|
||||
} catch (e) {
|
||||
console.warn("[Anomalous] Failed to fetch hashes for manual resolution", e);
|
||||
}
|
||||
|
||||
let fixed_count = 0;
|
||||
const hashes = (app.graph.extra && app.graph.extra.anomalous_hashes) || {};
|
||||
|
||||
// These are per-check results shown by Model Doctor. Clear stale evidence
|
||||
// before resolving the current graph again.
|
||||
for (const node of app.graph._nodes) {
|
||||
for (const widget of (node.widgets || [])) {
|
||||
delete widget.anomalous_resolution_status;
|
||||
delete widget.anomalous_size_candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const findHashData = (node, widget, value) => {
|
||||
let hashData = getWorkflowHash(node.id, value);
|
||||
if (!hashData && !requiresHashForModelRecovery(node, widget) && window.anomalous_hash_cache && typeof value === 'string') {
|
||||
const parts = value.split(/[/\\]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
const normVal = value.replace(/\\/g, '/');
|
||||
const cacheData = window.anomalous_hash_cache[value] || window.anomalous_hash_cache[normVal] || window.anomalous_hash_cache[basename];
|
||||
if (cacheData) hashData = typeof cacheData === 'string' ? { hash: cacheData, size: "" } : cacheData;
|
||||
}
|
||||
return hashData;
|
||||
};
|
||||
|
||||
// Resolve all model references in one request. The backend groups items by
|
||||
// required model category and scans each category once. Native slash-only
|
||||
// normalization remains in the existing loop below and needs no disk scan.
|
||||
const batchItems = [];
|
||||
for (const node of app.graph._nodes) {
|
||||
if (!node.widgets) continue;
|
||||
for (let i = 0; i < node.widgets.length; i++) {
|
||||
const widget = node.widgets[i];
|
||||
const value = widget.value;
|
||||
if (!isModelFilename(value)) continue;
|
||||
if (widget.options && widget.options.values && !widget.options.values.includes(value)) {
|
||||
const normalized = value.replace(/\\/g, '/');
|
||||
const nativeMatch = widget.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normalized);
|
||||
if (nativeMatch && nativeMatch !== value) continue;
|
||||
}
|
||||
const hashData = findHashData(node, widget, value);
|
||||
if (!hashData) continue;
|
||||
const hash = typeof hashData === 'string' ? hashData : (hashData.hash || "");
|
||||
const size = typeof hashData === 'string' ? "" : (hashData.size || "");
|
||||
if (!hash && !size) continue;
|
||||
batchItems.push({
|
||||
key: `${node.id}:${i}:${value}`,
|
||||
hash,
|
||||
size,
|
||||
type: inferExpectedModelTypes(node, widget)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const batchResults = new Map();
|
||||
if (batchItems.length) {
|
||||
try {
|
||||
for (let offset = 0; offset < batchItems.length; offset += 256) {
|
||||
const batchResponse = await fetch('/anomalous/resolve_hash_batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items: batchItems.slice(offset, offset + 256) })
|
||||
});
|
||||
if (!batchResponse.ok) throw new Error(`HTTP ${batchResponse.status}`);
|
||||
const batchData = await batchResponse.json();
|
||||
for (const entry of (batchData.results || [])) batchResults.set(entry.key, entry.result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Anomalous Hash Resolver] Batch resolution failed; falling back to single-item requests.', error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of app.graph._nodes) {
|
||||
if (node.widgets) {
|
||||
for (let i = 0; i < node.widgets.length; i++) {
|
||||
const w = node.widgets[i];
|
||||
const val = w.value;
|
||||
if (typeof val === 'string' && (val.endsWith('.safetensors') || val.endsWith('.ckpt') || val.endsWith('.pt'))) {
|
||||
|
||||
// Native slash mismatch fix: if val isn't in options, but matches if we normalize slashes
|
||||
if (w.options && w.options.values && !w.options.values.includes(val)) {
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const exactMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normVal);
|
||||
if (exactMatch && exactMatch !== val) {
|
||||
console.log(`[Anomalous Hash Resolver] Fixed slash mismatch: ${val} -> ${exactMatch}`);
|
||||
w.value = exactMatch;
|
||||
if (node.color === "#FF3333" || node.bgcolor === "#FF3333" || node.color === "#f66") {
|
||||
delete node.color;
|
||||
delete node.bgcolor;
|
||||
}
|
||||
if (w.callback) {
|
||||
w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
|
||||
}
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
continue; // Already fixed natively, skip backend lookup
|
||||
}
|
||||
}
|
||||
|
||||
let hashData = hashes[`${node.id}_${val}`];
|
||||
|
||||
// Fallback: If graph wasn't saved with hashes, check if the global cache knows this filename
|
||||
if (!hashData && window.anomalous_hash_cache) {
|
||||
const parts = val.split(/[/\\]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
const cache_data = window.anomalous_hash_cache[basename] || window.anomalous_hash_cache[val];
|
||||
if (cache_data) {
|
||||
hashData = typeof cache_data === 'string' ? { hash: cache_data, size: "" } : cache_data;
|
||||
}
|
||||
}
|
||||
|
||||
if (node.widgets) {
|
||||
for (let i = 0; i < node.widgets.length; i++) {
|
||||
const w = node.widgets[i];
|
||||
const val = w.value;
|
||||
if (isModelFilename(val)) {
|
||||
|
||||
// Native slash mismatch fix: if val isn't in options, but matches if we normalize slashes
|
||||
if (w.options && w.options.values && !w.options.values.includes(val)) {
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const exactMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normVal);
|
||||
if (exactMatch && exactMatch !== val) {
|
||||
console.log(`[Anomalous Hash Resolver] Fixed slash mismatch: ${val} -> ${exactMatch}`);
|
||||
w.value = exactMatch;
|
||||
const wIdx = node.widgets.indexOf(w);
|
||||
if (wIdx !== -1 && node.widgets_values) {
|
||||
node.widgets_values[wIdx] = exactMatch;
|
||||
}
|
||||
delete node.color;
|
||||
delete node.bgcolor;
|
||||
node.has_errors = false;
|
||||
|
||||
if (app.lastNodeErrors && app.lastNodeErrors[node.id]) {
|
||||
delete app.lastNodeErrors[node.id];
|
||||
}
|
||||
|
||||
if (w.callback) {
|
||||
w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
|
||||
}
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
fixed_count++; // Mark as fixed so the global graph update is triggered!
|
||||
continue; // Already fixed natively, skip backend lookup
|
||||
}
|
||||
}
|
||||
|
||||
const hashData = findHashData(node, w, val);
|
||||
|
||||
if (hashData) {
|
||||
let h = typeof hashData === 'string' ? hashData : hashData.hash;
|
||||
let s = typeof hashData === 'string' ? "" : (hashData.size || "");
|
||||
try {
|
||||
const res = await fetch(`/anomalous/resolve_hash?hash=${encodeURIComponent(h)}&size=${encodeURIComponent(s)}`);
|
||||
const resData = await res.json();
|
||||
const expectedTypes = inferExpectedModelTypes(node, w);
|
||||
const typeQuery = expectedTypes ? `&type=${encodeURIComponent(expectedTypes)}` : '';
|
||||
const batchKey = `${node.id}:${i}:${val}`;
|
||||
let resData = batchResults.get(batchKey);
|
||||
if (!batchResults.has(batchKey)) {
|
||||
const res = await fetch(`/anomalous/resolve_hash?hash=${encodeURIComponent(h)}&size=${encodeURIComponent(s)}${typeQuery}`);
|
||||
resData = await res.json();
|
||||
}
|
||||
|
||||
if (resData.identity_conflict) {
|
||||
w.anomalous_resolution_status = 'identity_conflict';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resData.confirmation_required) {
|
||||
w.anomalous_resolution_status = 'size_candidate';
|
||||
w.anomalous_size_candidate = {
|
||||
filename: resData.filename,
|
||||
type: resData.type,
|
||||
size: resData.size,
|
||||
};
|
||||
|
||||
// Background checks may discover a candidate, but only an
|
||||
// explicit manual check is allowed to ask for and apply it.
|
||||
if (!is_manual || silent) continue;
|
||||
const confirmed = await anomalousConfirm(
|
||||
translate('doctorSizeCandidatePrompt', {
|
||||
original: val,
|
||||
candidate: resData.filename,
|
||||
size: resData.size ?? s ?? '',
|
||||
}),
|
||||
translate('doctorSizeCandidateTitle'),
|
||||
{
|
||||
okLabel: translate('doctorUseSizeCandidate'),
|
||||
cancelLabel: translate('doctorKeepMissing'),
|
||||
},
|
||||
);
|
||||
if (!confirmed) continue;
|
||||
resData = { ...resData, found: true, manually_confirmed: true };
|
||||
}
|
||||
|
||||
if (resData.found) {
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const normRes = resData.filename.replace(/\\/g, '/');
|
||||
|
||||
let finalValue = resData.filename;
|
||||
let optionsCacheStale = false;
|
||||
if (w.options && w.options.values) {
|
||||
const exactMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normRes);
|
||||
if (exactMatch) {
|
||||
finalValue = exactMatch;
|
||||
} else {
|
||||
optionsCacheStale = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalValue !== val || normRes !== normVal || optionsCacheStale || node.has_errors || node.color) {
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const normRes = resData.filename.replace(/\\/g, '/');
|
||||
|
||||
let finalValue = resData.filename;
|
||||
let optionsCacheStale = false;
|
||||
if (w.options && w.options.values) {
|
||||
const exactMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normRes);
|
||||
if (exactMatch) {
|
||||
finalValue = exactMatch;
|
||||
} else {
|
||||
optionsCacheStale = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalValue !== val || normRes !== normVal || optionsCacheStale || node.has_errors || node.color) {
|
||||
if (optionsCacheStale) {
|
||||
console.log(`[Anomalous Hash Resolver] Found ${finalValue} on disk, but ComfyUI frontend dropdown is stale. Forcing backend cache clear...`);
|
||||
let refreshedMatch = null;
|
||||
try {
|
||||
await fetch('/anomalous/clear_cache', { method: 'POST' });
|
||||
await app.refreshComboInNodes();
|
||||
// Re-evaluate exact match after refreshing
|
||||
if (w.options && w.options.values) {
|
||||
const newMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normRes);
|
||||
if (newMatch) finalValue = newMatch;
|
||||
else w.options.values.push(finalValue); // Absolute fallback
|
||||
refreshedMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normRes) || null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to clear cache:", e);
|
||||
}
|
||||
if (!refreshedMatch) {
|
||||
console.warn(`[Anomalous Hash Resolver] Rejected cross-type or invalid model path for ${node.type}.${w.name}: ${finalValue}`);
|
||||
continue;
|
||||
}
|
||||
finalValue = refreshedMatch;
|
||||
}
|
||||
|
||||
console.log(`[Anomalous Hash Resolver] Auto-fixed missing model: ${val} -> ${finalValue}`);
|
||||
w.value = finalValue;
|
||||
delete node.color;
|
||||
delete node.bgcolor;
|
||||
node.has_errors = false;
|
||||
if (w.callback) {
|
||||
w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
|
||||
}
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
fixed_count++;
|
||||
}
|
||||
} else if (!is_manual || force_prompt) {
|
||||
showMissingModelPrompt(val, h);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Anomalous Hash Resolver] Error:", err);
|
||||
if (!is_manual || force_prompt) {
|
||||
showMissingModelPrompt(val, h);
|
||||
}
|
||||
}
|
||||
} else if (!is_manual || force_prompt) {
|
||||
showMissingModelPrompt(val, "Unknown (Not Scanned)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const repairKind = resData.manually_confirmed ? 'Manually confirmed' : 'Auto-fixed';
|
||||
console.log(`[Anomalous Hash Resolver] ${repairKind} missing model: ${val} -> ${finalValue}`);
|
||||
w.value = finalValue;
|
||||
const wIdx = node.widgets.indexOf(w);
|
||||
if (wIdx !== -1 && node.widgets_values) {
|
||||
node.widgets_values[wIdx] = finalValue;
|
||||
}
|
||||
delete node.color;
|
||||
delete node.bgcolor;
|
||||
node.has_errors = false;
|
||||
node.anomalous_auto_resolved = !resData.manually_confirmed;
|
||||
node.anomalous_original_missing_val = val;
|
||||
delete w.anomalous_resolution_status;
|
||||
delete w.anomalous_size_candidate;
|
||||
if (w.callback) {
|
||||
w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
|
||||
}
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
fixed_count++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Anomalous Hash Resolver] Error:", err);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fixed_count > 0) {
|
||||
// Force ComfyUI v1 side panels (Workflow Overview/Parameters) to re-evaluate and clear errors
|
||||
if (app.graph && app.graph.change) app.graph.change();
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("graphChanged"));
|
||||
} catch (e) { }
|
||||
|
||||
// Deep clear ComfyUI native error caches
|
||||
if (app.lastNodeErrors) {
|
||||
Object.keys(app.lastNodeErrors).forEach(key => delete app.lastNodeErrors[key]);
|
||||
}
|
||||
}
|
||||
|
||||
if (fixed_count > 0) {
|
||||
// Force ComfyUI v1 side panels (Workflow Overview/Parameters) to re-evaluate and clear errors
|
||||
if (app.graph && app.graph.change) app.graph.change();
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("graphChanged"));
|
||||
} catch (e) { }
|
||||
|
||||
// Deep clear ComfyUI native error caches
|
||||
if (app.lastNodeErrors) app.lastNodeErrors = null;
|
||||
if (typeof app.clearErrors === 'function') app.clearErrors();
|
||||
|
||||
// Note: We removed the aggressive "ghost clicker" that automatically clicked the Refresh button in the Vue side panel.
|
||||
// It was too intrusive and could cause focus issues. Instead, we now gently remind the user via an alert.
|
||||
// 注:我们移除了主动点击 Vue 侧边栏刷新按钮的“幽灵连点器”,因为它侵入性过强且容易引发焦点问题。改为在弹窗中进行善意提醒。
|
||||
}
|
||||
|
||||
if (is_manual) {
|
||||
// Detect current UI language (Default to 'zh' if not set)
|
||||
// 获取当前界面语言(未设置时默认使用中文)
|
||||
const lang = (window.anomalous_browser_lang === 'en') ? 'en' : 'zh';
|
||||
|
||||
if (typeof app.clearErrors === 'function') app.clearErrors();
|
||||
|
||||
// Note: We removed the aggressive "ghost clicker" that automatically clicked the Refresh button in the Vue side panel.
|
||||
// It was too intrusive and could cause focus issues. Instead, we now gently remind the user via an alert.
|
||||
// 注:我们移除了主动点击 Vue 侧边栏刷新按钮的“幽灵连点器”,因为它侵入性过强且容易引发焦点问题。改为在弹窗中进行善意提醒。
|
||||
}
|
||||
|
||||
if (is_manual && !silent) {
|
||||
if (fixed_count > 0) {
|
||||
if (lang === 'en') {
|
||||
alert(`🪄 Anomalous successfully fixed ${fixed_count} missing model(s)!\n\n💡 Tip: If you still see errors in the "Workflow Overview" side panel, simply click its [Refresh] button to clear them.`);
|
||||
} else {
|
||||
alert(`🪄 Anomalous 成功修复了 ${fixed_count} 个缺失的模型!\n\n💡 提示:如果侧边栏【工作流总览】中依然显示红色报错,顺手点击一下该面板里的【刷新】按钮即可清除。`);
|
||||
}
|
||||
} else if (!force_prompt) {
|
||||
if (lang === 'en') {
|
||||
alert(`🪄 No models could be auto-fixed.\nIf nodes are still red, please open the Anomalous plugin window and click the 🔄 "Scan" button at the bottom to fetch missing hashes, or download the models from Civitai.`);
|
||||
} else {
|
||||
alert(`🪄 未发现可以自动修复的模型。\n如果依然有飘红,请点开 Anomalous 插件悬浮窗,点击底部的 🔄【扫描】按钮获取最新模型信息,或去 C站 下载缺失模型。`);
|
||||
}
|
||||
alert(translate('hashResolverFixed', { count: fixed_count }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function showMissingModelPrompt(filename, hash) {
|
||||
const currentLang = window.anomalous_browser_lang || 'zh';
|
||||
const existing = document.getElementById('anomalous-missing-prompt');
|
||||
let container;
|
||||
if (!existing) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'anomalous-missing-prompt';
|
||||
container.style.position = 'fixed';
|
||||
container.style.top = '50%';
|
||||
container.style.left = '50%';
|
||||
container.style.transform = 'translate(-50%, -50%)';
|
||||
container.style.background = 'var(--comfy-menu-bg, #222)';
|
||||
container.style.border = '1px solid var(--border-color, #444)';
|
||||
container.style.padding = '20px';
|
||||
container.style.borderRadius = '8px';
|
||||
container.style.zIndex = '9999';
|
||||
container.style.color = '#fff';
|
||||
container.style.boxShadow = '0 4px 12px rgba(0,0,0,0.5)';
|
||||
container.style.minWidth = '350px';
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.innerText = currentLang === 'zh' ? '⚠️ 缺失模型提醒' : '⚠️ Missing Models Alert';
|
||||
title.style.marginTop = '0';
|
||||
title.style.color = '#ff6b6b';
|
||||
|
||||
const desc = document.createElement('p');
|
||||
desc.style.fontSize = '0.9em';
|
||||
desc.style.color = '#ccc';
|
||||
desc.innerHTML = currentLang === 'zh'
|
||||
? '点击下方的一键扫描会自动获取所有缺失模型的最新哈希信息。<br><br><span style="color:#ffc107">⚠️ 注意:</span>此操作<b>不会</b>重命名您的文件,也<b>不会</b>下载预览图,仅仅为了快速点亮红框节点。<br>如需获取完整的模型封面和规范化重命名,请打开 Anomalous 插件窗口,点击底部的 🔄 按钮执行标准扫描。'
|
||||
: 'Clicking Scan will automatically fetch the latest hash info for all missing models.<br><br><span style="color:#ffc107">⚠️ Note:</span> This action <b>will not</b> rename your files or download cover images. It is only meant to fix the red nodes quickly.<br>For full cover downloads and standard renaming, open the Anomalous plugin and run a full scan.';
|
||||
|
||||
container.appendChild(title);
|
||||
container.appendChild(desc);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.id = 'anomalous-missing-list';
|
||||
list.style.maxHeight = '200px';
|
||||
list.style.overflowY = 'auto';
|
||||
list.style.marginBottom = '15px';
|
||||
container.appendChild(list);
|
||||
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.style.display = 'flex';
|
||||
btnRow.style.gap = '10px';
|
||||
btnRow.style.justifyContent = 'flex-end';
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.innerText = currentLang === 'zh' ? '忽略并手动处理' : 'Ignore & Handle Manually';
|
||||
closeBtn.style.padding = '6px 12px';
|
||||
closeBtn.style.background = '#444';
|
||||
closeBtn.style.color = '#fff';
|
||||
closeBtn.style.border = 'none';
|
||||
closeBtn.style.borderRadius = '4px';
|
||||
closeBtn.style.cursor = 'pointer';
|
||||
closeBtn.onclick = () => container.remove();
|
||||
|
||||
const scanBtn = document.createElement('button');
|
||||
scanBtn.innerText = currentLang === 'zh' ? '一键扫描 (不改名)' : 'Quick Scan (No Rename)';
|
||||
scanBtn.style.padding = '6px 12px';
|
||||
scanBtn.style.background = '#007bff';
|
||||
scanBtn.style.color = '#fff';
|
||||
scanBtn.style.border = 'none';
|
||||
scanBtn.style.borderRadius = '4px';
|
||||
scanBtn.style.cursor = 'pointer';
|
||||
scanBtn.onclick = async () => {
|
||||
scanBtn.innerText = '启动中...';
|
||||
scanBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/anomalous/scan_all', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
scanBtn.innerText = currentLang === 'zh' ? '正在后台极速获取信息中,请稍候...' : 'Fetching in background, please wait...';
|
||||
|
||||
const pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const statusRes = await fetch('/anomalous/global_scan_status');
|
||||
const statusData = await statusRes.json();
|
||||
if (!statusData.scanning) {
|
||||
clearInterval(pollTimer);
|
||||
container.remove();
|
||||
// Add a tiny delay to ensure disk is flushed
|
||||
setTimeout(async () => {
|
||||
alert(currentLang === 'zh' ? '信息获取完成!正在为您自动点亮缺失模型...' : 'Scan complete! Resolving missing models...');
|
||||
await window.anomalous_resolve_all_missing_nodes(true);
|
||||
}, 500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Polling error", e);
|
||||
}
|
||||
}, 2000);
|
||||
} else {
|
||||
alert((currentLang === 'zh' ? '扫描启动失败: ' : 'Scan failed: ') + data.message);
|
||||
scanBtn.disabled = false;
|
||||
scanBtn.innerText = currentLang === 'zh' ? '一键扫描 (不改名)' : 'Quick Scan (No Rename)';
|
||||
}
|
||||
} catch (err) {
|
||||
alert(currentLang === 'zh' ? '请求失败' : 'Request failed');
|
||||
scanBtn.disabled = false;
|
||||
scanBtn.innerText = currentLang === 'zh' ? '一键扫描 (不改名)' : 'Quick Scan (No Rename)';
|
||||
}
|
||||
};
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.innerText = currentLang === 'zh' ? '复制所有 C站 下载链接' : 'Copy Civitai Links';
|
||||
copyBtn.style.padding = '6px 12px';
|
||||
copyBtn.style.background = '#28a745';
|
||||
copyBtn.style.color = '#fff';
|
||||
copyBtn.style.border = 'none';
|
||||
copyBtn.style.borderRadius = '4px';
|
||||
copyBtn.style.cursor = 'pointer';
|
||||
copyBtn.onclick = () => {
|
||||
const links = Array.from(list.querySelectorAll('a')).map(a => a.href).join('\n');
|
||||
navigator.clipboard.writeText(links).then(() => {
|
||||
const oldText = copyBtn.innerText;
|
||||
copyBtn.innerText = '已复制!';
|
||||
setTimeout(() => copyBtn.innerText = oldText, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const openAllBtn = document.createElement('button');
|
||||
openAllBtn.innerText = currentLang === 'zh' ? '一键全部跳转' : 'Open All Links';
|
||||
openAllBtn.style.padding = '6px 12px';
|
||||
openAllBtn.style.background = '#17a2b8';
|
||||
openAllBtn.style.color = '#fff';
|
||||
openAllBtn.style.border = 'none';
|
||||
openAllBtn.style.borderRadius = '4px';
|
||||
openAllBtn.style.cursor = 'pointer';
|
||||
openAllBtn.onclick = () => {
|
||||
const links = Array.from(list.querySelectorAll('a')).map(a => a.href);
|
||||
if (links.length === 0) return;
|
||||
links.forEach(link => {
|
||||
window.open(link, '_blank');
|
||||
});
|
||||
};
|
||||
|
||||
btnRow.appendChild(closeBtn);
|
||||
btnRow.appendChild(scanBtn);
|
||||
btnRow.appendChild(copyBtn);
|
||||
btnRow.appendChild(openAllBtn);
|
||||
container.appendChild(btnRow);
|
||||
|
||||
document.body.appendChild(container);
|
||||
} else {
|
||||
container = existing;
|
||||
}
|
||||
|
||||
const list = container.querySelector('#anomalous-missing-list');
|
||||
const item = document.createElement('div');
|
||||
item.style.marginBottom = '8px';
|
||||
item.style.borderBottom = '1px solid #333';
|
||||
item.style.paddingBottom = '8px';
|
||||
item.innerHTML = `<strong style="word-break: break-all;">${filename}</strong> <br><span style="font-size:0.8em; color:#aaa;">Hash: ${hash}</span>
|
||||
<a href="https://civitai.com/search/models?sortBy=models_v9&query=${hash}" target="_blank" style="color:#4dabf7; font-size:0.8em; margin-left:10px; text-decoration:none;">${window.anomalous_current_lang === 'en' ? '[Download on Civitai]' : '[去C站下载]'}</a>`;
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Anomalous.ModelBrowser.HashResolver",
|
||||
|
||||
async setup() {
|
||||
// Pre-fetch all hashes on startup so that dragging generated images (without opening UI) still intercepts
|
||||
try {
|
||||
if (localStorage.getItem('anomalous_auto_scan_enabled') === 'true') {
|
||||
const resp = await fetch('/anomalous/all_hashes');
|
||||
const data = await resp.json();
|
||||
// api_get_all_hashes returns a flat dictionary, not {hashes: {...}}
|
||||
window.anomalous_hash_cache = data.hashes ? data.hashes : data;
|
||||
} else {
|
||||
window.anomalous_hash_cache = {};
|
||||
}
|
||||
window.anomalous_is_empty_state = Object.keys(window.anomalous_hash_cache).length === 0;
|
||||
} catch (e) {
|
||||
window.anomalous_hash_cache = {};
|
||||
window.anomalous_is_empty_state = true;
|
||||
console.warn("[Anomalous] Failed to fetch initial hashes", e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
app.registerExtension({
|
||||
name: "Anomalous.ModelBrowser.HashResolver",
|
||||
|
||||
async setup() {
|
||||
// Expose global reload function so scans can trigger it
|
||||
window.anomalous_reload_hashes = async function () {
|
||||
try {
|
||||
const resp = await fetch('/anomalous/all_hashes');
|
||||
const data = await resp.json();
|
||||
window.anomalous_hash_cache = data.hashes ? data.hashes : data;
|
||||
window.anomalous_is_empty_state = Object.keys(window.anomalous_hash_cache).length === 0;
|
||||
} catch (e) {
|
||||
window.anomalous_hash_cache = {};
|
||||
window.anomalous_is_empty_state = true;
|
||||
console.warn("[Anomalous] Failed to fetch hashes", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-fetch all hashes on startup so that dragging generated images (without opening UI) still intercepts
|
||||
await window.anomalous_reload_hashes();
|
||||
|
||||
// Intercept graph serialization to inject hashes. ComfyUI 0.27 no
|
||||
// longer guarantees a global LGraph symbol, so prefer the graph's
|
||||
// actual constructor and fail closed if the graph API is unavailable.
|
||||
// This resolver is optional and must never prevent the main browser
|
||||
// extension from registering its visible entry point.
|
||||
const graphClass = [app.graph?.constructor, globalThis.LGraph].find((candidate) => (
|
||||
candidate?.prototype && typeof candidate.prototype.serialize === 'function'
|
||||
));
|
||||
if (!graphClass) {
|
||||
console.warn('[Anomalous Hash Resolver] Graph serialization API is unavailable; resolver disabled for this session.');
|
||||
return;
|
||||
}
|
||||
if (graphClass.prototype.__anomalousHashResolverPatched) return;
|
||||
|
||||
|
||||
|
||||
// Intercept graph serialization to inject hashes
|
||||
const origSerialize = LGraph.prototype.serialize;
|
||||
const origSerialize = graphClass.prototype.serialize;
|
||||
window.anomalous_has_warned_unscanned = false;
|
||||
window.anomalous_unscanned_models = [];
|
||||
LGraph.prototype.serialize = function () {
|
||||
graphClass.prototype.__anomalousHashResolverPatched = true;
|
||||
graphClass.prototype.serialize = function () {
|
||||
const data = origSerialize.apply(this, arguments);
|
||||
|
||||
if (localStorage.getItem('anomalous_inject_hash') === 'false') {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Clone extra to avoid mutating the live graph's extra object
|
||||
const extraObj = data.extra ? JSON.parse(JSON.stringify(data.extra)) : {};
|
||||
const existingProvenance = {
|
||||
extra: {
|
||||
anomalous_hashes: extraObj.anomalous_hashes
|
||||
? JSON.parse(JSON.stringify(extraObj.anomalous_hashes))
|
||||
: {},
|
||||
},
|
||||
};
|
||||
extraObj.anomalous_hashes = {};
|
||||
const liveSources = (this.extra && this.extra.anomalous_model_sources) ||
|
||||
(app.graph?.extra && app.graph.extra.anomalous_model_sources) ||
|
||||
extraObj.anomalous_model_sources || null;
|
||||
if (liveSources && typeof liveSources === 'object') {
|
||||
extraObj.anomalous_model_sources = JSON.parse(JSON.stringify(liveSources));
|
||||
}
|
||||
let unscanned_models = [];
|
||||
|
||||
if (data.nodes) {
|
||||
@@ -405,26 +425,54 @@ app.registerExtension({
|
||||
|
||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||
for (const val of node.widgets_values) {
|
||||
if (typeof val === 'string' && (val.endsWith('.safetensors') || val.endsWith('.ckpt') || val.endsWith('.pt'))) {
|
||||
if (isModelFilename(val)) {
|
||||
const parts = val.split(/[/\\]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
|
||||
let valIsMissing = false;
|
||||
let matchingWidget = null;
|
||||
if (liveNode && liveNode.widgets) {
|
||||
const matchingWidget = liveNode.widgets.find(w => w.value === val && w.type === "combo");
|
||||
matchingWidget = liveNode.widgets.find(w => w.value === val && w.type === "combo") || null;
|
||||
if (matchingWidget && matchingWidget.options && matchingWidget.options.values && !matchingWidget.options.values.includes(val)) {
|
||||
valIsMissing = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!valIsMissing) {
|
||||
const cache_data = window.anomalous_hash_cache[basename] || window.anomalous_hash_cache[val];
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const cache_data = window.anomalous_hash_cache[val] || window.anomalous_hash_cache[normVal] || window.anomalous_hash_cache[basename];
|
||||
if (cache_data) {
|
||||
if (typeof cache_data === 'string') {
|
||||
extraObj.anomalous_hashes[`${node.id}_${val}`] = { hash: cache_data, size: "" };
|
||||
} else {
|
||||
extraObj.anomalous_hashes[`${node.id}_${val}`] = cache_data;
|
||||
let hashObj = typeof cache_data === 'string' ? { hash: cache_data, size: "" } : cache_data;
|
||||
if (matchingWidget && requiresHashForModelRecovery(liveNode, matchingWidget) && !hashObj.hash) {
|
||||
const preserved = findWorkflowHashRecord(existingProvenance, node.id, val);
|
||||
if (preserved?.hash) hashObj = preserved;
|
||||
else {
|
||||
if (!unscanned_models.includes(basename)) unscanned_models.push(basename);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
extraObj.anomalous_hashes[`${node.id}_${val}`] = hashObj;
|
||||
if (normVal !== val) {
|
||||
extraObj.anomalous_hashes[`${node.id}_${normVal}`] = hashObj;
|
||||
}
|
||||
} else {
|
||||
const preserved = findWorkflowHashRecord(existingProvenance, node.id, val);
|
||||
if (preserved) {
|
||||
extraObj.anomalous_hashes[`${node.id}_${val}`] = preserved;
|
||||
if (normVal !== val) extraObj.anomalous_hashes[`${node.id}_${normVal}`] = preserved;
|
||||
} else if (!unscanned_models.includes(basename)) {
|
||||
unscanned_models.push(basename);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Missing dropdown values cannot be rediscovered from the
|
||||
// local cache. Preserve recipe/workflow provenance so Model
|
||||
// Doctor can still recover the exact appended reference.
|
||||
const preserved = findWorkflowHashRecord(existingProvenance, node.id, val);
|
||||
if (preserved) {
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
extraObj.anomalous_hashes[`${node.id}_${val}`] = preserved;
|
||||
if (normVal !== val) extraObj.anomalous_hashes[`${node.id}_${normVal}`] = preserved;
|
||||
} else {
|
||||
if (!unscanned_models.includes(basename)) {
|
||||
unscanned_models.push(basename);
|
||||
@@ -436,29 +484,18 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
}
|
||||
data.extra = extraObj;
|
||||
|
||||
data.extra = extraObj;
|
||||
window.anomalous_unscanned_models = unscanned_models;
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Intercept loadGraphData to resolve missing models
|
||||
const origLoadGraphData = app.loadGraphData;
|
||||
app.loadGraphData = function (graphData) {
|
||||
// Proceed with original loadGraphData synchronously first
|
||||
const ret = origLoadGraphData.apply(this, arguments);
|
||||
|
||||
// After graph is loaded, we async fix the missing nodes
|
||||
if (graphData && graphData.extra && graphData.extra.anomalous_hashes) {
|
||||
setTimeout(async () => {
|
||||
await window.anomalous_resolve_all_missing_nodes(false);
|
||||
}, 200); // Small delay to ensure ComfyUI has finished rendering/validating
|
||||
}
|
||||
|
||||
return ret;
|
||||
};
|
||||
if (typeof app.loadGraphData === 'function') {
|
||||
const origLoadGraphData = app.loadGraphData;
|
||||
app.loadGraphData = function (graphData) {
|
||||
// Proceed with original loadGraphData synchronously first
|
||||
return origLoadGraphData.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { showDetail } from './ui_detail.js';
|
||||
import { showEditModal } from './ui_model_editor.js';
|
||||
import { _openAdvancedModelSelector, setWidgetValuePath } from './ui_model_selector.js';
|
||||
import { loadModels, applyModelToCanvas, stopMediaInContainer } from './ui_grid.js';
|
||||
import { createDOM, renderSidebar, loadFolders } from './ui_sidebar.js';
|
||||
import { closeWorkspace, hideAllPanels } from './ui_browser_navigation.js';
|
||||
import { openScanWizard, triggerDirectModelScan } from './ui_scan_wizard.js';
|
||||
import { openFolderManager } from './ui_folder_manager.js';
|
||||
import { showHelp } from './ui_help.js';
|
||||
import { loadGalleryImages, refreshGalleryImages, showGeneratedGallery, showGallerySelectMode, showGalleryViewer } from './ui_gallery.js';
|
||||
import { showNotebooks, refreshNotebooks, saveCurrentNotebook, deleteCurrentNotebook } from './ui_notebooks.js';
|
||||
import { renderNotebookEditor, fillNotebookGalleries } from './ui_notebook_editor.js';
|
||||
import { sendNotebookToCanvas } from './notebook_canvas.js';
|
||||
import { showRecipes, refreshRecipes } from './ui_recipe_catalog.js';
|
||||
import { renderRecipeList, handleSaveRecipe } from './ui_recipes.js';
|
||||
import { showMaterials, refreshMaterials, openSavedMaterial, openMaterialLibrary } from './ui_materials.js';
|
||||
import { openPromptStudio } from './ui_prompt_composer.js';
|
||||
import { openPromptTranslator } from './ui_prompt_translator.js';
|
||||
import { closeUpdateGuide } from './ui_update_guide.js';
|
||||
import { showImageWorkbench } from './ui_gallery_detail.js';
|
||||
import { initDoctorPanel, diagnoseNode, renderGlobalDashboard, openLoraInsertionPicker, runGlobalDoctorScan } from './ui_doctor.js';
|
||||
import { initAssistantPanel, renderAssistantModelCard, _loadAssistantHistory } from './ui_node_assistant.js';
|
||||
import { _openGalleryReplacer } from './ui_node_model_picker.js';
|
||||
|
||||
export class AnomalousBrowser {
|
||||
constructor() {
|
||||
this.modal = null;
|
||||
this.sidebar = null;
|
||||
this.grid = null;
|
||||
this.detailPanel = null;
|
||||
this.currentType = 'loras';
|
||||
this.currentPathIdx = 0;
|
||||
this.currentSubfolder = '/';
|
||||
this.foldersData = null;
|
||||
this.expandedFolders = new Set(['/', 'checkpoints', 'loras', 'unet', 'diffusion_models']);
|
||||
this.energySaving = localStorage.getItem('anomalous_energy_saving') === 'true';
|
||||
this.cardThumbnailMode = localStorage.getItem('anomalous_card_thumbnail_mode') === 'original'
|
||||
? 'original'
|
||||
: 'balanced';
|
||||
this.entryMode = 'floating';
|
||||
this.createDOM();
|
||||
}
|
||||
|
||||
show() {
|
||||
if (this._idleReleaseTimer) {
|
||||
clearTimeout(this._idleReleaseTimer);
|
||||
this._idleReleaseTimer = null;
|
||||
}
|
||||
this.setTriggerVisible(false);
|
||||
this.modal.classList.add('visible');
|
||||
if (!this.foldersData) {
|
||||
this.loadFolders();
|
||||
} else {
|
||||
this.loadModels();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
closeUpdateGuide(this);
|
||||
this.modal.classList.remove('visible');
|
||||
this.setTriggerVisible(true);
|
||||
const canvas = document.getElementById('graph-canvas');
|
||||
if (canvas instanceof HTMLElement) canvas.focus({ preventScroll: true });
|
||||
if (this._modelLoadController) this._modelLoadController.abort();
|
||||
if (this._modelMediaObserver) this._modelMediaObserver.disconnect();
|
||||
this.modal.querySelectorAll('video, audio').forEach(media => media.pause());
|
||||
this.stopMediaInContainer(this.grid);
|
||||
if (this._idleReleaseTimer) clearTimeout(this._idleReleaseTimer);
|
||||
this._idleReleaseTimer = setTimeout(() => {
|
||||
if (this.modal.classList.contains('visible')) return;
|
||||
this.stopMediaInContainer(this.grid);
|
||||
this.grid.replaceChildren();
|
||||
this.models = [];
|
||||
}, 90000);
|
||||
}
|
||||
|
||||
setTriggerVisible(visible) {
|
||||
const trigger = this.triggerButton || document.getElementById('anomalous-trigger-btn');
|
||||
trigger?.classList.toggle('anomalous-trigger-hidden', !visible || this.entryMode !== 'floating');
|
||||
}
|
||||
}
|
||||
|
||||
AnomalousBrowser.prototype.initDoctorPanel = initDoctorPanel;
|
||||
AnomalousBrowser.prototype.diagnoseNode = diagnoseNode;
|
||||
AnomalousBrowser.prototype.renderGlobalDashboard = renderGlobalDashboard;
|
||||
AnomalousBrowser.prototype.initAssistantPanel = initAssistantPanel;
|
||||
AnomalousBrowser.prototype.renderAssistantModelCard = renderAssistantModelCard;
|
||||
AnomalousBrowser.prototype._loadAssistantHistory = _loadAssistantHistory;
|
||||
AnomalousBrowser.prototype._openGalleryReplacer = _openGalleryReplacer;
|
||||
AnomalousBrowser.prototype.openLoraInsertionPicker = openLoraInsertionPicker;
|
||||
AnomalousBrowser.prototype.runGlobalDoctorScan = runGlobalDoctorScan;
|
||||
|
||||
AnomalousBrowser.prototype.showNotebooks = showNotebooks;
|
||||
AnomalousBrowser.prototype.closeWorkspace = closeWorkspace;
|
||||
AnomalousBrowser.prototype.refreshNotebooks = refreshNotebooks;
|
||||
AnomalousBrowser.prototype.saveCurrentNotebook = saveCurrentNotebook;
|
||||
AnomalousBrowser.prototype.deleteCurrentNotebook = deleteCurrentNotebook;
|
||||
AnomalousBrowser.prototype.renderNotebookEditor = renderNotebookEditor;
|
||||
AnomalousBrowser.prototype.fillNotebookGalleries = fillNotebookGalleries;
|
||||
AnomalousBrowser.prototype.sendNotebookToCanvas = sendNotebookToCanvas;
|
||||
|
||||
AnomalousBrowser.prototype.showRecipes = showRecipes;
|
||||
AnomalousBrowser.prototype.refreshRecipes = refreshRecipes;
|
||||
AnomalousBrowser.prototype.renderRecipeList = renderRecipeList;
|
||||
AnomalousBrowser.prototype.handleSaveRecipe = handleSaveRecipe;
|
||||
AnomalousBrowser.prototype.showMaterials = showMaterials;
|
||||
AnomalousBrowser.prototype.openSavedMaterial = openSavedMaterial;
|
||||
AnomalousBrowser.prototype.openMaterialLibrary = openMaterialLibrary;
|
||||
AnomalousBrowser.prototype.openPromptStudio = openPromptStudio;
|
||||
AnomalousBrowser.prototype.openPromptTranslator = function() { openPromptTranslator(this); };
|
||||
AnomalousBrowser.prototype.refreshMaterials = refreshMaterials;
|
||||
|
||||
AnomalousBrowser.prototype.loadGalleryImages = loadGalleryImages;
|
||||
AnomalousBrowser.prototype.refreshGalleryImages = refreshGalleryImages;
|
||||
AnomalousBrowser.prototype.showGeneratedGallery = showGeneratedGallery;
|
||||
AnomalousBrowser.prototype.showGallerySelectMode = showGallerySelectMode;
|
||||
AnomalousBrowser.prototype.showGalleryViewer = showGalleryViewer;
|
||||
AnomalousBrowser.prototype.showImageWorkbench = showImageWorkbench;
|
||||
|
||||
AnomalousBrowser.prototype.createDOM = createDOM;
|
||||
AnomalousBrowser.prototype.openScanWizard = openScanWizard;
|
||||
AnomalousBrowser.prototype.scanSingleModel = triggerDirectModelScan;
|
||||
AnomalousBrowser.prototype.openFolderManager = openFolderManager;
|
||||
AnomalousBrowser.prototype.renderSidebar = renderSidebar;
|
||||
AnomalousBrowser.prototype.loadFolders = loadFolders;
|
||||
AnomalousBrowser.prototype.showHelp = showHelp;
|
||||
AnomalousBrowser.prototype.hideAllPanels = hideAllPanels;
|
||||
|
||||
AnomalousBrowser.prototype.loadModels = loadModels;
|
||||
AnomalousBrowser.prototype.applyModelToCanvas = applyModelToCanvas;
|
||||
AnomalousBrowser.prototype.stopMediaInContainer = stopMediaInContainer;
|
||||
|
||||
AnomalousBrowser.prototype.showDetail = showDetail;
|
||||
AnomalousBrowser.prototype.showEditModal = showEditModal;
|
||||
AnomalousBrowser.prototype._openAdvancedModelSelector = _openAdvancedModelSelector;
|
||||
AnomalousBrowser.prototype.setWidgetValuePath = setWidgetValuePath;
|
||||
@@ -0,0 +1,482 @@
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem('anomalous_btn_x');
|
||||
localStorage.removeItem('anomalous_btn_y');
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { AnomalousBrowser } from './browser.js';
|
||||
import {
|
||||
clampFloatingTriggerPosition,
|
||||
clearSavedTriggerPosition,
|
||||
DEFAULT_SAFE_LEFT,
|
||||
DEFAULT_SAFE_TOP,
|
||||
isValidSavedTriggerPosition,
|
||||
loadSavedTriggerPosition,
|
||||
normalizeEntryMode,
|
||||
normalizeFloatingTriggerSize,
|
||||
normalizeFloatingTriggerStyle,
|
||||
saveTriggerPosition
|
||||
} from './entry_controls.js?v=20260921-flicker-free-1';
|
||||
import {
|
||||
createShortcutSettingControl,
|
||||
DEFAULT_BROWSER_SHORTCUT,
|
||||
DEFAULT_MATERIALS_SHORTCUT,
|
||||
installDeferredShortcutFallback,
|
||||
} from './shortcut_controls.js';
|
||||
|
||||
export const ENTRY_MODE_SETTING_ID = 'Anomalous.ModelBrowser.EntryMode';
|
||||
export const SHORTCUT_SETTING_ID = 'Anomalous.ModelBrowser.Shortcut';
|
||||
export const MATERIALS_SHORTCUT_SETTING_ID = 'Anomalous.ModelBrowser.MaterialsShortcut';
|
||||
export const FLOATING_TRIGGER_SIZE_SETTING_ID = 'Anomalous.ModelBrowser.FloatingTriggerSize';
|
||||
export const FLOATING_TRIGGER_STYLE_SETTING_ID = 'Anomalous.ModelBrowser.FloatingTriggerStyle';
|
||||
export const OPEN_BROWSER_COMMAND_ID = 'Anomalous.ModelBrowser.Open';
|
||||
export const OPEN_MATERIALS_COMMAND_ID = 'Anomalous.ModelBrowser.OpenMaterials';
|
||||
export const RESET_TRIGGER_POSITION_COMMAND_ID = 'Anomalous.ModelBrowser.ResetFloatingTriggerPosition';
|
||||
|
||||
export function createBrowserEntry({ translate, getCurrentLanguage }) {
|
||||
let entryMode = 'floating';
|
||||
let floatingTriggerSize = 'medium';
|
||||
let floatingTriggerStyle = 'icon';
|
||||
let browserInstance = null;
|
||||
let triggerButton = null;
|
||||
let triggerBoundsUpdater = null;
|
||||
let disposeMaterialsShortcutFallback = null;
|
||||
const t = translate;
|
||||
|
||||
function getSettingTranslationPatches() {
|
||||
const category = t('mainInterfaceCategory');
|
||||
return {
|
||||
[FLOATING_TRIGGER_STYLE_SETTING_ID]: {
|
||||
name: t('mainFloatingTriggerStyleSetting'),
|
||||
category: ['Anomalous Model Browser', category, 'floating-trigger-style'],
|
||||
tooltip: t('mainFloatingTriggerStyleTooltip'),
|
||||
options: [
|
||||
{ value: 'icon', text: t('mainFloatingTriggerStyleIcon') },
|
||||
{ value: 'pill', text: t('mainFloatingTriggerStylePill') }
|
||||
]
|
||||
},
|
||||
[FLOATING_TRIGGER_SIZE_SETTING_ID]: {
|
||||
name: t('mainFloatingTriggerSizeSetting'),
|
||||
category: ['Anomalous Model Browser', category, 'floating-trigger-size'],
|
||||
tooltip: t('mainFloatingTriggerSizeTooltip'),
|
||||
options: [
|
||||
{ value: 'small', text: t('mainFloatingTriggerSizeSmall') },
|
||||
{ value: 'medium', text: t('mainFloatingTriggerSizeMedium') },
|
||||
{ value: 'large', text: t('mainFloatingTriggerSizeLarge') }
|
||||
]
|
||||
},
|
||||
[SHORTCUT_SETTING_ID]: {
|
||||
name: t('mainShortcutSetting'),
|
||||
category: ['Anomalous Model Browser', category, 'shortcut'],
|
||||
tooltip: t('mainShortcutTooltip')
|
||||
},
|
||||
[MATERIALS_SHORTCUT_SETTING_ID]: {
|
||||
name: t('mainMaterialsShortcutSetting'),
|
||||
category: ['Anomalous Model Browser', category, 'materials-shortcut'],
|
||||
tooltip: t('mainMaterialsShortcutTooltip')
|
||||
},
|
||||
[ENTRY_MODE_SETTING_ID]: {
|
||||
name: t('mainEntryModeSetting'),
|
||||
category: ['Anomalous Model Browser', category, 'entry-mode'],
|
||||
tooltip: t('mainEntryModeTooltip'),
|
||||
options: [
|
||||
{ value: 'floating', text: t('mainEntryModeFloating') },
|
||||
{ value: 'topbar', text: t('mainEntryModeTopbar') },
|
||||
{ value: 'menu', text: t('mainEntryModeMenu') }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function refreshRegisteredSettings() {
|
||||
const settingsApi = app.extensionManager?.setting;
|
||||
const registry = settingsApi?.settings?.value || settingsApi?.settings;
|
||||
if (!registry || typeof registry !== 'object') return;
|
||||
for (const [id, patch] of Object.entries(getSettingTranslationPatches())) {
|
||||
if (registry[id]) registry[id] = { ...registry[id], ...patch };
|
||||
}
|
||||
}
|
||||
|
||||
function syncVisibility() {
|
||||
document.documentElement.classList.toggle('anomalous-topbar-entry-enabled', entryMode === 'topbar');
|
||||
document.documentElement.classList.toggle('anomalous-floating-entry-enabled', entryMode === 'floating');
|
||||
const browserIsOpen = browserInstance?.modal?.classList.contains('visible') === true;
|
||||
triggerButton?.classList.toggle('anomalous-trigger-hidden', entryMode !== 'floating' || browserIsOpen);
|
||||
}
|
||||
|
||||
function applyPresentation() {
|
||||
if (!triggerButton) return;
|
||||
triggerButton.dataset.size = floatingTriggerSize;
|
||||
triggerButton.dataset.style = floatingTriggerStyle;
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'anomalous-trigger-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;display:block;"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'anomalous-trigger-label';
|
||||
label.textContent = t('mainFloatingTriggerLabel');
|
||||
triggerButton.replaceChildren(icon, label);
|
||||
triggerButton.title = t('mainOpenTitle');
|
||||
triggerButton.setAttribute('aria-label', t('mainOpenTitle'));
|
||||
if (loadSavedTriggerPosition()) {
|
||||
requestAnimationFrame(() => triggerBoundsUpdater?.());
|
||||
}
|
||||
}
|
||||
|
||||
function resetPosition() {
|
||||
clearSavedTriggerPosition();
|
||||
if (!triggerButton) return;
|
||||
triggerButton.style.left = '';
|
||||
triggerButton.style.top = '';
|
||||
triggerButton.style.right = '';
|
||||
triggerButton.style.bottom = '';
|
||||
}
|
||||
|
||||
function ensureBrowser() {
|
||||
if (browserInstance) return browserInstance;
|
||||
try {
|
||||
browserInstance = new AnomalousBrowser();
|
||||
browserInstance.entryMode = entryMode;
|
||||
browserInstance.triggerButton = triggerButton;
|
||||
window.anomalousBrowserInstance = browserInstance;
|
||||
triggerButton?.classList.remove('anomalous-trigger-error');
|
||||
if (triggerButton) {
|
||||
triggerButton.title = t('mainOpenTitle');
|
||||
triggerButton.setAttribute('aria-label', t('mainOpenTitle'));
|
||||
}
|
||||
syncVisibility();
|
||||
return browserInstance;
|
||||
} catch (error) {
|
||||
console.error('[Anomalous Model Browser] UI initialization failed:', error);
|
||||
triggerButton?.classList.add('anomalous-trigger-error');
|
||||
if (triggerButton) {
|
||||
triggerButton.title = getCurrentLanguage() === 'zh' ? t('mainRetryInit') : t('mainRetryInitEn');
|
||||
triggerButton.setAttribute('aria-label', triggerButton.title);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
ensureBrowser()?.show();
|
||||
}
|
||||
|
||||
async function openMaterials() {
|
||||
const browser = ensureBrowser();
|
||||
if (!browser) return;
|
||||
await browser.openMaterialLibrary();
|
||||
}
|
||||
|
||||
function getMaterialsShortcutCombo() {
|
||||
const commands = app.extensionManager?.command?.commands;
|
||||
const command = Array.isArray(commands)
|
||||
? commands.find(item => item.id === OPEN_MATERIALS_COMMAND_ID)
|
||||
: null;
|
||||
return command ? command.keybinding?.combo || null : DEFAULT_MATERIALS_SHORTCUT;
|
||||
}
|
||||
|
||||
function materialLibraryIsOpen() {
|
||||
return browserInstance?.modal?.classList.contains('visible') === true
|
||||
&& browserInstance?.nbPanel?.style.display === 'flex'
|
||||
&& browserInstance?.materialContainer?.style.display === 'flex';
|
||||
}
|
||||
|
||||
function installMaterialsShortcutFallback() {
|
||||
disposeMaterialsShortcutFallback?.();
|
||||
disposeMaterialsShortcutFallback = installDeferredShortcutFallback({
|
||||
target: window,
|
||||
getCombo: getMaterialsShortcutCombo,
|
||||
isHandled: materialLibraryIsOpen,
|
||||
onFallback: openMaterials,
|
||||
onError: error => console.error('[Anomalous Model Browser] Material shortcut failed:', error),
|
||||
});
|
||||
}
|
||||
|
||||
const translationPatches = getSettingTranslationPatches();
|
||||
const settings = [
|
||||
{
|
||||
id: FLOATING_TRIGGER_STYLE_SETTING_ID,
|
||||
...translationPatches[FLOATING_TRIGGER_STYLE_SETTING_ID],
|
||||
type: 'combo',
|
||||
defaultValue: 'icon',
|
||||
onChange(value) {
|
||||
floatingTriggerStyle = normalizeFloatingTriggerStyle(value);
|
||||
applyPresentation();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: FLOATING_TRIGGER_SIZE_SETTING_ID,
|
||||
...translationPatches[FLOATING_TRIGGER_SIZE_SETTING_ID],
|
||||
type: 'combo',
|
||||
defaultValue: 'medium',
|
||||
onChange(value) {
|
||||
floatingTriggerSize = normalizeFloatingTriggerSize(value);
|
||||
applyPresentation();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: SHORTCUT_SETTING_ID,
|
||||
...translationPatches[SHORTCUT_SETTING_ID],
|
||||
type: () => createShortcutSettingControl({ app, commandId: OPEN_BROWSER_COMMAND_ID, translate: t }),
|
||||
defaultValue: '',
|
||||
telemetry: { trackChanges: false }
|
||||
},
|
||||
{
|
||||
id: MATERIALS_SHORTCUT_SETTING_ID,
|
||||
...translationPatches[MATERIALS_SHORTCUT_SETTING_ID],
|
||||
type: () => createShortcutSettingControl({
|
||||
app,
|
||||
commandId: OPEN_MATERIALS_COMMAND_ID,
|
||||
translate: t,
|
||||
settingLabelKey: 'mainMaterialsShortcutSetting',
|
||||
}),
|
||||
defaultValue: '',
|
||||
telemetry: { trackChanges: false }
|
||||
},
|
||||
{
|
||||
id: ENTRY_MODE_SETTING_ID,
|
||||
...translationPatches[ENTRY_MODE_SETTING_ID],
|
||||
type: 'combo',
|
||||
defaultValue: 'floating',
|
||||
onChange(value) {
|
||||
entryMode = normalizeEntryMode(value);
|
||||
if (browserInstance) browserInstance.entryMode = entryMode;
|
||||
syncVisibility();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
window.addEventListener('anomalous-language-change', () => {
|
||||
applyPresentation();
|
||||
refreshRegisteredSettings();
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=20260927-hotfix-1';
|
||||
if (!document.querySelector('link[href^="/extensions/Anomalous_Model_Browser/styles.css"]')) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.type = 'text/css';
|
||||
link.href = cssUrl;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
try {
|
||||
const registeredSettings = app.extensionManager?.setting;
|
||||
entryMode = normalizeEntryMode(registeredSettings?.get(ENTRY_MODE_SETTING_ID));
|
||||
floatingTriggerSize = normalizeFloatingTriggerSize(registeredSettings?.get(FLOATING_TRIGGER_SIZE_SETTING_ID));
|
||||
floatingTriggerStyle = normalizeFloatingTriggerStyle(registeredSettings?.get(FLOATING_TRIGGER_STYLE_SETTING_ID));
|
||||
} catch (error) {
|
||||
console.warn('[Anomalous Model Browser] Unable to read entry preferences:', error);
|
||||
}
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'anomalous-trigger-btn';
|
||||
btn.setAttribute('aria-label', 'Anomalous Model Browser');
|
||||
// Prevent FOUC: Start hidden until presentation & exact coordinates are bound
|
||||
btn.classList.add('anomalous-trigger-initializing');
|
||||
triggerButton = btn;
|
||||
if (browserInstance) browserInstance.triggerButton = btn;
|
||||
btn.title = t('mainOpenTitle');
|
||||
|
||||
// Pre-configure content & presentation BEFORE mounting
|
||||
applyPresentation();
|
||||
|
||||
// Pre-calculate and assign exact target coordinates BEFORE appending to DOM
|
||||
const savedInitialPos = loadSavedTriggerPosition();
|
||||
if (savedInitialPos) {
|
||||
const estSize = floatingTriggerSize === 'small' ? 44 : (floatingTriggerSize === 'large' ? 76 : 60);
|
||||
const initialClamped = clampFloatingTriggerPosition({
|
||||
x: savedInitialPos.x,
|
||||
y: savedInitialPos.y,
|
||||
width: estSize,
|
||||
height: estSize,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
minX: 70
|
||||
});
|
||||
btn.style.position = 'fixed';
|
||||
btn.style.left = initialClamped.x + 'px';
|
||||
btn.style.top = initialClamped.y + 'px';
|
||||
btn.style.right = 'auto';
|
||||
btn.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
let isDragging = false;
|
||||
let hasMoved = false;
|
||||
let startPointerX = 0;
|
||||
let startPointerY = 0;
|
||||
let startBtnLeft = 0;
|
||||
let startBtnTop = 0;
|
||||
let currentDragX = null;
|
||||
let currentDragY = null;
|
||||
let activePointerId = null;
|
||||
|
||||
const onPointerMove = (event) => {
|
||||
if (!isDragging) return;
|
||||
const dx = event.clientX - startPointerX;
|
||||
const dy = event.clientY - startPointerY;
|
||||
if (!hasMoved && Math.hypot(dx, dy) >= 4) {
|
||||
hasMoved = true;
|
||||
}
|
||||
if (!hasMoved) return;
|
||||
event.preventDefault();
|
||||
const btnW = btn.offsetWidth || 60;
|
||||
const btnH = btn.offsetHeight || 60;
|
||||
const clamped = clampFloatingTriggerPosition({
|
||||
x: startBtnLeft + dx,
|
||||
y: startBtnTop + dy,
|
||||
width: btnW,
|
||||
height: btnH,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
minX: 70
|
||||
});
|
||||
currentDragX = clamped.x;
|
||||
currentDragY = clamped.y;
|
||||
btn.style.left = clamped.x + 'px';
|
||||
btn.style.top = clamped.y + 'px';
|
||||
btn.style.right = 'auto';
|
||||
btn.style.bottom = 'auto';
|
||||
};
|
||||
|
||||
const finishDrag = (event) => {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
|
||||
btn.removeEventListener('pointermove', onPointerMove);
|
||||
btn.removeEventListener('pointerup', finishDrag);
|
||||
btn.removeEventListener('pointercancel', finishDrag);
|
||||
btn.removeEventListener('lostpointercapture', finishDrag);
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', finishDrag);
|
||||
window.removeEventListener('pointercancel', finishDrag);
|
||||
|
||||
if (activePointerId != null) {
|
||||
try {
|
||||
if (btn.hasPointerCapture(activePointerId)) {
|
||||
btn.releasePointerCapture(activePointerId);
|
||||
}
|
||||
} catch (_) {}
|
||||
activePointerId = null;
|
||||
}
|
||||
|
||||
btn.style.transition = '';
|
||||
|
||||
if (hasMoved && currentDragX != null && currentDragY != null) {
|
||||
saveTriggerPosition({ x: currentDragX, y: currentDragY });
|
||||
} else if (!hasMoved) {
|
||||
open();
|
||||
}
|
||||
};
|
||||
|
||||
btn.addEventListener('pointerdown', (event) => {
|
||||
if (event.button !== 0) return;
|
||||
isDragging = true;
|
||||
hasMoved = false;
|
||||
startPointerX = event.clientX;
|
||||
startPointerY = event.clientY;
|
||||
activePointerId = event.pointerId;
|
||||
|
||||
const rect = btn.getBoundingClientRect();
|
||||
startBtnLeft = rect.left;
|
||||
startBtnTop = rect.top;
|
||||
currentDragX = rect.left;
|
||||
currentDragY = rect.top;
|
||||
|
||||
btn.style.transition = 'none';
|
||||
|
||||
try {
|
||||
btn.setPointerCapture(event.pointerId);
|
||||
} catch (_) {}
|
||||
|
||||
btn.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||
btn.addEventListener('pointerup', finishDrag);
|
||||
btn.addEventListener('pointercancel', finishDrag);
|
||||
btn.addEventListener('lostpointercapture', finishDrag);
|
||||
window.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||
window.addEventListener('pointerup', finishDrag);
|
||||
window.addEventListener('pointercancel', finishDrag);
|
||||
});
|
||||
|
||||
const updateBtnBounds = () => {
|
||||
const saved = loadSavedTriggerPosition();
|
||||
if (saved) {
|
||||
const estFallback = floatingTriggerSize === 'small' ? 44 : (floatingTriggerSize === 'large' ? 76 : 60);
|
||||
const btnW = btn.offsetWidth || estFallback;
|
||||
const btnH = btn.offsetHeight || btnW;
|
||||
const clamped = clampFloatingTriggerPosition({
|
||||
x: saved.x,
|
||||
y: saved.y,
|
||||
width: btnW,
|
||||
height: btnH,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
minX: 70
|
||||
});
|
||||
btn.style.left = clamped.x + 'px';
|
||||
btn.style.top = clamped.y + 'px';
|
||||
btn.style.right = 'auto';
|
||||
btn.style.bottom = 'auto';
|
||||
} else {
|
||||
btn.style.left = '';
|
||||
btn.style.top = '';
|
||||
btn.style.right = '';
|
||||
btn.style.bottom = '';
|
||||
}
|
||||
};
|
||||
triggerBoundsUpdater = updateBtnBounds;
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
updateBtnBounds();
|
||||
syncVisibility();
|
||||
});
|
||||
|
||||
// Mount pre-positioned and styled element to DOM
|
||||
document.body.appendChild(btn);
|
||||
syncVisibility();
|
||||
ensureBrowser();
|
||||
installMaterialsShortcutFallback();
|
||||
|
||||
// Reveal smoothly on first animation frame without any positional jump or flicker
|
||||
requestAnimationFrame(() => {
|
||||
updateBtnBounds();
|
||||
btn.classList.remove('anomalous-trigger-initializing');
|
||||
});
|
||||
|
||||
window.anomalousDragGhostImg = new Image();
|
||||
window.anomalousDragGhostImg.src = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='80'><rect width='76' height='76' x='2' y='2' fill='%23140812' fill-opacity='0.85' rx='16' stroke='%23f59e0b' stroke-width='2'/><text x='40' y='50' font-family='sans-serif' font-size='32' font-weight='bold' fill='%23f59e0b' text-anchor='middle'>W</text></svg>";
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
actionBarButtons: [{
|
||||
icon: 'pi pi-box',
|
||||
label: t('mainTopbarTriggerLabel'),
|
||||
tooltip: t('mainOpenTitle'),
|
||||
class: 'anomalous-topbar-entry',
|
||||
onClick: open
|
||||
}],
|
||||
commands: [
|
||||
{ id: OPEN_BROWSER_COMMAND_ID, label: t('mainOpenTitle'), function: open },
|
||||
{ id: OPEN_MATERIALS_COMMAND_ID, label: t('mainOpenMaterialsTitle'), function: openMaterials },
|
||||
{ id: RESET_TRIGGER_POSITION_COMMAND_ID, label: t('mainResetFloatingTriggerPosition'), function: resetPosition }
|
||||
],
|
||||
keybindings: [
|
||||
{ combo: DEFAULT_BROWSER_SHORTCUT, commandId: OPEN_BROWSER_COMMAND_ID },
|
||||
{ combo: DEFAULT_MATERIALS_SHORTCUT, commandId: OPEN_MATERIALS_COMMAND_ID },
|
||||
],
|
||||
menuCommands: [{
|
||||
path: ['Extensions', 'Anomalous Model Browser'],
|
||||
commands: [OPEN_BROWSER_COMMAND_ID, OPEN_MATERIALS_COMMAND_ID, RESET_TRIGGER_POSITION_COMMAND_ID]
|
||||
}],
|
||||
setup,
|
||||
open,
|
||||
openMaterials,
|
||||
ensureBrowser,
|
||||
resetPosition,
|
||||
syncVisibility
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Unconditionally purge legacy contaminated storage keys on module load
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem('anomalous_btn_x');
|
||||
localStorage.removeItem('anomalous_btn_y');
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
export const FLOATING_TRIGGER_SIZES = new Set(['small', 'medium', 'large']);
|
||||
export const FLOATING_TRIGGER_STYLES = new Set(['icon', 'pill']);
|
||||
export const ENTRY_MODES = new Set(['floating', 'topbar', 'menu']);
|
||||
|
||||
export function normalizeFloatingTriggerSize(value) {
|
||||
return FLOATING_TRIGGER_SIZES.has(value) ? value : 'medium';
|
||||
}
|
||||
|
||||
export function normalizeFloatingTriggerStyle(value) {
|
||||
return FLOATING_TRIGGER_STYLES.has(value) ? value : 'icon';
|
||||
}
|
||||
|
||||
export function normalizeEntryMode(value) {
|
||||
return ENTRY_MODES.has(value) ? value : 'floating';
|
||||
}
|
||||
|
||||
export const DEFAULT_SAFE_TOP = 80;
|
||||
export const DEFAULT_SAFE_LEFT = 80; // Safe position outside ComfyUI left sidebar dock
|
||||
export const DEFAULT_SAFE_MARGIN_RIGHT = 24;
|
||||
export const DEFAULT_MIN_SAFE_X = 70; // Avoid ComfyUI left sidebar dock
|
||||
export const DEFAULT_TRIGGER_POSITION = Object.freeze({ top: DEFAULT_SAFE_TOP, left: DEFAULT_SAFE_LEFT });
|
||||
export const TRIGGER_POSITION_STORAGE_KEY = 'anomalous_trigger_pos_v3';
|
||||
export const LEGACY_TRIGGER_X_KEY = 'anomalous_btn_x';
|
||||
export const LEGACY_TRIGGER_Y_KEY = 'anomalous_btn_y';
|
||||
|
||||
export function isValidSavedTriggerPosition(x, y) {
|
||||
if (x == null || y == null) return false;
|
||||
const px = Number.parseFloat(x);
|
||||
const py = Number.parseFloat(y);
|
||||
// Must be a finite number, py >= 0, and px >= DEFAULT_MIN_SAFE_X (70px) to prevent docking inside sidebar
|
||||
return Number.isFinite(px) && Number.isFinite(py) && px >= DEFAULT_MIN_SAFE_X && py >= 0;
|
||||
}
|
||||
|
||||
export function loadSavedTriggerPosition(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
|
||||
if (!storage) return null;
|
||||
try {
|
||||
const raw = storage.getItem(TRIGGER_POSITION_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (isValidSavedTriggerPosition(parsed?.x, parsed?.y)) {
|
||||
return { x: Math.round(Number(parsed.x)), y: Math.round(Number(parsed.y)) };
|
||||
} else {
|
||||
storage.removeItem(TRIGGER_POSITION_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function saveTriggerPosition(pos, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
|
||||
if (!storage || !pos) return;
|
||||
const x = Number.parseFloat(pos.x);
|
||||
const y = Number.parseFloat(pos.y);
|
||||
if (!isValidSavedTriggerPosition(x, y)) return;
|
||||
try {
|
||||
storage.setItem(TRIGGER_POSITION_STORAGE_KEY, JSON.stringify({
|
||||
x: Math.round(x),
|
||||
y: Math.round(y)
|
||||
}));
|
||||
storage.removeItem(LEGACY_TRIGGER_X_KEY);
|
||||
storage.removeItem(LEGACY_TRIGGER_Y_KEY);
|
||||
storage.removeItem('anomalous_trigger_pos_v2');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export function clearSavedTriggerPosition(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
|
||||
if (!storage) return;
|
||||
try {
|
||||
storage.removeItem(TRIGGER_POSITION_STORAGE_KEY);
|
||||
storage.removeItem(LEGACY_TRIGGER_X_KEY);
|
||||
storage.removeItem(LEGACY_TRIGGER_Y_KEY);
|
||||
storage.removeItem('anomalous_trigger_pos_v2');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export function sanitizeSavedTriggerPosition(x, y) {
|
||||
if (!isValidSavedTriggerPosition(x, y)) return null;
|
||||
return { x: Number.parseFloat(x), y: Number.parseFloat(y) };
|
||||
}
|
||||
|
||||
export function normalizeSavedTriggerPosition(x, y) {
|
||||
return sanitizeSavedTriggerPosition(x, y);
|
||||
}
|
||||
|
||||
export function clampFloatingTriggerPosition({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
minX = DEFAULT_MIN_SAFE_X,
|
||||
margin = 0
|
||||
}) {
|
||||
const safeWidth = Math.max(1, Number(width) || 60);
|
||||
const safeHeight = Math.max(1, Number(height) || 60);
|
||||
const rawVw = Number(viewportWidth) || (typeof window !== 'undefined' ? window.innerWidth : 1024) || 1024;
|
||||
const rawVh = Number(viewportHeight) || (typeof window !== 'undefined' ? window.innerHeight : 768) || 768;
|
||||
const safeVw = rawVw >= 300 ? rawVw : 1024;
|
||||
const safeVh = rawVh >= 200 ? rawVh : 768;
|
||||
const maxX = Math.max(minX, safeVw - safeWidth);
|
||||
const maxY = Math.max(0, safeVh - safeHeight);
|
||||
|
||||
const parsedX = Number.parseFloat(x);
|
||||
const parsedY = Number.parseFloat(y);
|
||||
|
||||
const fallbackX = Math.max(minX, Math.min(maxX, DEFAULT_SAFE_LEFT));
|
||||
const fallbackY = Math.min(maxY, DEFAULT_SAFE_TOP);
|
||||
|
||||
const targetX = Number.isFinite(parsedX) ? parsedX : fallbackX;
|
||||
const targetY = Number.isFinite(parsedY) ? parsedY : fallbackY;
|
||||
|
||||
return {
|
||||
x: Math.min(maxX, Math.max(minX, Math.round(targetX))),
|
||||
y: Math.min(maxY, Math.max(0, Math.round(targetY)))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
const MODEL_CHAIN_TYPES = Object.freeze(['MODEL', 'CLIP']);
|
||||
|
||||
function normalizeSlotType(type) {
|
||||
return String(type ?? '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
function findSlotIndex(slots, requiredType) {
|
||||
const wanted = normalizeSlotType(requiredType);
|
||||
return Array.isArray(slots)
|
||||
? slots.findIndex(slot => normalizeSlotType(slot?.type) === wanted)
|
||||
: -1;
|
||||
}
|
||||
|
||||
function getGraphLink(graph, linkId) {
|
||||
if (linkId === null || linkId === undefined) return null;
|
||||
return graph?.links?.[linkId] || graph?._links?.[linkId] || null;
|
||||
}
|
||||
|
||||
function getGraphNode(graph, nodeId) {
|
||||
if (graph?.getNodeById) return graph.getNodeById(nodeId) || null;
|
||||
return Array.isArray(graph?._nodes)
|
||||
? graph._nodes.find(node => node?.id === nodeId) || null
|
||||
: null;
|
||||
}
|
||||
|
||||
function unsupported(direction, code, details = {}) {
|
||||
return { supported: false, direction, code, channels: [], ...details };
|
||||
}
|
||||
|
||||
export function analyzeModelChainInsertion(graph, anchorNode, direction) {
|
||||
if (!graph || !anchorNode) {
|
||||
return unsupported(direction, 'missing_graph_or_node');
|
||||
}
|
||||
if (direction !== 'before' && direction !== 'after') {
|
||||
return unsupported(direction, 'invalid_direction');
|
||||
}
|
||||
|
||||
const channels = [];
|
||||
for (const type of MODEL_CHAIN_TYPES) {
|
||||
const anchorSlot = direction === 'before'
|
||||
? findSlotIndex(anchorNode.inputs, type)
|
||||
: findSlotIndex(anchorNode.outputs, type);
|
||||
if (anchorSlot < 0) {
|
||||
return unsupported(direction, direction === 'before' ? 'missing_chain_inputs' : 'missing_chain_outputs', { missingType: type });
|
||||
}
|
||||
|
||||
if (direction === 'before') {
|
||||
const linkId = anchorNode.inputs[anchorSlot]?.link;
|
||||
const link = getGraphLink(graph, linkId);
|
||||
const sourceNode = link ? getGraphNode(graph, link.origin_id) : null;
|
||||
if (!link || !sourceNode) {
|
||||
return unsupported(direction, 'unconnected_chain_inputs', { missingType: type });
|
||||
}
|
||||
channels.push({
|
||||
type,
|
||||
anchorSlot,
|
||||
originalLinks: [{
|
||||
originNode: sourceNode,
|
||||
originSlot: link.origin_slot,
|
||||
targetNode: anchorNode,
|
||||
targetSlot: anchorSlot,
|
||||
}],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkIds = Array.isArray(anchorNode.outputs[anchorSlot]?.links)
|
||||
? anchorNode.outputs[anchorSlot].links.filter(id => id !== null && id !== undefined)
|
||||
: [];
|
||||
if (linkIds.length > 1) {
|
||||
return unsupported(direction, 'ambiguous_downstream_branches', { ambiguousType: type, branchCount: linkIds.length });
|
||||
}
|
||||
|
||||
const originalLinks = [];
|
||||
for (const linkId of linkIds) {
|
||||
const link = getGraphLink(graph, linkId);
|
||||
const targetNode = link ? getGraphNode(graph, link.target_id) : null;
|
||||
if (!link || !targetNode) {
|
||||
return unsupported(direction, 'invalid_downstream_link', { invalidType: type });
|
||||
}
|
||||
originalLinks.push({
|
||||
originNode: anchorNode,
|
||||
originSlot: anchorSlot,
|
||||
targetNode,
|
||||
targetSlot: link.target_slot,
|
||||
});
|
||||
}
|
||||
channels.push({ type, anchorSlot, originalLinks });
|
||||
}
|
||||
|
||||
return { supported: true, direction, code: 'ready', channels };
|
||||
}
|
||||
|
||||
export function getModelChainInsertionCapabilities(graph, anchorNode) {
|
||||
return {
|
||||
before: analyzeModelChainInsertion(graph, anchorNode, 'before'),
|
||||
after: analyzeModelChainInsertion(graph, anchorNode, 'after'),
|
||||
};
|
||||
}
|
||||
|
||||
function assertInsertedNodeSlots(insertedNode) {
|
||||
const slots = {};
|
||||
for (const type of MODEL_CHAIN_TYPES) {
|
||||
const input = findSlotIndex(insertedNode?.inputs, type);
|
||||
const output = findSlotIndex(insertedNode?.outputs, type);
|
||||
if (input < 0 || output < 0) {
|
||||
const error = new Error(`Inserted node does not expose ${type} input/output slots.`);
|
||||
error.code = 'inserted_node_missing_chain_slots';
|
||||
throw error;
|
||||
}
|
||||
slots[type] = { input, output };
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
function connectOrThrow(originNode, originSlot, targetNode, targetSlot, type) {
|
||||
const link = originNode?.connect?.(originSlot, targetNode, targetSlot);
|
||||
if (!link) {
|
||||
const error = new Error(`Failed to connect ${type} while inserting the model node.`);
|
||||
error.code = 'connection_failed';
|
||||
error.channelType = type;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreConnections(originalConnections) {
|
||||
for (const connection of originalConnections) {
|
||||
connection.originNode?.connect?.(
|
||||
connection.originSlot,
|
||||
connection.targetNode,
|
||||
connection.targetSlot,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function placeInsertedNode(graph, anchorNode, insertedNode, direction) {
|
||||
const anchorX = Number(anchorNode?.pos?.[0]) || 0;
|
||||
const anchorY = Number(anchorNode?.pos?.[1]) || 0;
|
||||
const anchorWidth = Number(anchorNode?.size?.[0]) || 220;
|
||||
const insertedWidth = Number(insertedNode?.size?.[0]) || 220;
|
||||
const horizontalGap = 90;
|
||||
let x = direction === 'before'
|
||||
? anchorX - insertedWidth - horizontalGap
|
||||
: anchorX + anchorWidth + horizontalGap;
|
||||
let y = anchorY;
|
||||
|
||||
const overlaps = (candidateX, candidateY) => (graph?._nodes || []).some(node => {
|
||||
if (!node || node === anchorNode || node === insertedNode) return false;
|
||||
const nodeX = Number(node.pos?.[0]) || 0;
|
||||
const nodeY = Number(node.pos?.[1]) || 0;
|
||||
const nodeWidth = Number(node.size?.[0]) || 220;
|
||||
const nodeHeight = Number(node.size?.[1]) || 120;
|
||||
const insertedHeight = Number(insertedNode?.size?.[1]) || 120;
|
||||
return candidateX < nodeX + nodeWidth + 20
|
||||
&& candidateX + insertedWidth + 20 > nodeX
|
||||
&& candidateY < nodeY + nodeHeight + 20
|
||||
&& candidateY + insertedHeight + 20 > nodeY;
|
||||
});
|
||||
|
||||
for (const offset of [0, 80, -80, 160, -160, 240, -240]) {
|
||||
if (!overlaps(x, anchorY + offset)) {
|
||||
y = anchorY + offset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
insertedNode.pos = [x, y];
|
||||
}
|
||||
|
||||
export function spliceModelChainNode({ graph, anchorNode, insertedNode, direction }) {
|
||||
const analysis = analyzeModelChainInsertion(graph, anchorNode, direction);
|
||||
if (!analysis.supported) {
|
||||
const error = new Error(`Model-chain insertion is not available: ${analysis.code}`);
|
||||
error.code = analysis.code;
|
||||
error.analysis = analysis;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const insertedSlots = assertInsertedNodeSlots(insertedNode);
|
||||
const originalConnections = analysis.channels.flatMap(channel => channel.originalLinks);
|
||||
let added = false;
|
||||
graph.beforeChange?.(anchorNode);
|
||||
try {
|
||||
graph.add(insertedNode);
|
||||
added = true;
|
||||
placeInsertedNode(graph, anchorNode, insertedNode, direction);
|
||||
|
||||
for (const channel of analysis.channels) {
|
||||
const slots = insertedSlots[channel.type];
|
||||
if (direction === 'before') {
|
||||
const original = channel.originalLinks[0];
|
||||
connectOrThrow(original.originNode, original.originSlot, insertedNode, slots.input, channel.type);
|
||||
connectOrThrow(insertedNode, slots.output, anchorNode, channel.anchorSlot, channel.type);
|
||||
continue;
|
||||
}
|
||||
|
||||
connectOrThrow(anchorNode, channel.anchorSlot, insertedNode, slots.input, channel.type);
|
||||
for (const original of channel.originalLinks) {
|
||||
connectOrThrow(insertedNode, slots.output, original.targetNode, original.targetSlot, channel.type);
|
||||
}
|
||||
}
|
||||
|
||||
graph.afterChange?.(anchorNode);
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
return insertedNode;
|
||||
} catch (error) {
|
||||
if (added) graph.remove?.(insertedNode);
|
||||
restoreConnections(originalConnections);
|
||||
graph.afterChange?.(anchorNode);
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { normalizeLocale, resolveLocale, translate } from './locales.js';
|
||||
|
||||
export const LANGUAGE_SETTING_ID = 'Anomalous.ModelBrowser.Language';
|
||||
export const ABYSSAL_SCARLET_SETTING_ID = 'Anomalous.ModelBrowser.AbyssalScarletTheme';
|
||||
|
||||
let defaultLang = 'zh';
|
||||
try {
|
||||
let comfyDetected = false;
|
||||
const aglLang = localStorage.getItem('Comfy.Settings.AIGODLIKE-COMFYUI-TRANSLATION.Language');
|
||||
if (aglLang) {
|
||||
defaultLang = aglLang.toLowerCase().includes('en') ? 'en' : 'zh';
|
||||
comfyDetected = true;
|
||||
} else {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key || (!key.toLowerCase().includes('lang') && !key.toLowerCase().includes('locale'))) continue;
|
||||
const value = localStorage.getItem(key);
|
||||
if (typeof value !== 'string') continue;
|
||||
const normalizedValue = value.toLowerCase();
|
||||
if (normalizedValue.includes('zh') || normalizedValue.includes('chinese')) {
|
||||
defaultLang = 'zh';
|
||||
comfyDetected = true;
|
||||
break;
|
||||
}
|
||||
if (normalizedValue.includes('en') || normalizedValue.includes('english')) {
|
||||
defaultLang = 'en';
|
||||
comfyDetected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!comfyDetected && navigator.language && !navigator.language.toLowerCase().startsWith('zh')) {
|
||||
defaultLang = 'en';
|
||||
}
|
||||
} catch (_) {
|
||||
if (navigator.language && !navigator.language.toLowerCase().startsWith('zh')) {
|
||||
defaultLang = 'en';
|
||||
}
|
||||
}
|
||||
|
||||
let currentLang = resolveLocale(localStorage.getItem('anomalous_lang') || defaultLang);
|
||||
window.anomalous_browser_lang = currentLang;
|
||||
|
||||
export const t = (key, params) => translate(key, params, window.anomalous_browser_lang || currentLang);
|
||||
export const getCurrentLanguage = () => currentLang;
|
||||
|
||||
function normalizeLanguagePreference(value) {
|
||||
return value === 'zh' || value === 'en' ? value : 'auto';
|
||||
}
|
||||
|
||||
function resolveComfyLanguage() {
|
||||
try {
|
||||
const settings = app.extensionManager?.setting;
|
||||
const locale = settings?.get('Comfy.Locale')
|
||||
|| app.ui?.settings?.getSettingValue?.('Comfy.Locale')
|
||||
|| app.ui?.settings?.getSettingValue?.('Comfy.Locale.Language');
|
||||
return normalizeLocale(locale) || defaultLang;
|
||||
} catch (_) {
|
||||
return defaultLang;
|
||||
}
|
||||
}
|
||||
|
||||
function getSettingTranslationPatches() {
|
||||
const category = t('mainInterfaceCategory');
|
||||
return {
|
||||
[LANGUAGE_SETTING_ID]: {
|
||||
name: t('mainLanguageSetting'),
|
||||
category: ['Anomalous Model Browser', category, 'language'],
|
||||
tooltip: t('mainLanguageTooltip'),
|
||||
options: [
|
||||
{ value: 'auto', text: t('mainLanguageAuto') },
|
||||
{ value: 'zh', text: t('mainLanguageChinese') },
|
||||
{ value: 'en', text: t('mainLanguageEnglish') }
|
||||
]
|
||||
},
|
||||
[ABYSSAL_SCARLET_SETTING_ID]: {
|
||||
name: t('mainAbyssalScarletThemeSetting'),
|
||||
category: ['Anomalous Model Browser', category, 'theme'],
|
||||
tooltip: t('mainAbyssalScarletThemeTooltip')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function refreshRegisteredSettings() {
|
||||
const settingsApi = app.extensionManager?.setting;
|
||||
const registry = settingsApi?.settings?.value || settingsApi?.settings;
|
||||
if (!registry || typeof registry !== 'object') return;
|
||||
for (const [id, patch] of Object.entries(getSettingTranslationPatches())) {
|
||||
if (registry[id]) registry[id] = { ...registry[id], ...patch };
|
||||
}
|
||||
}
|
||||
|
||||
function applyLanguagePreference(value) {
|
||||
const preference = normalizeLanguagePreference(value);
|
||||
if (preference === 'auto') localStorage.removeItem('anomalous_lang');
|
||||
else localStorage.setItem('anomalous_lang', preference);
|
||||
|
||||
const nextLanguage = preference === 'auto' ? resolveComfyLanguage() : preference;
|
||||
const changed = nextLanguage !== window.anomalous_browser_lang;
|
||||
currentLang = nextLanguage;
|
||||
window.anomalous_browser_lang = nextLanguage;
|
||||
refreshRegisteredSettings();
|
||||
if (changed) {
|
||||
window.dispatchEvent(new CustomEvent('anomalous-language-change', {
|
||||
detail: { language: nextLanguage, preference }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (!localStorage.getItem('anomalous_lang')) {
|
||||
currentLang = resolveComfyLanguage();
|
||||
window.anomalous_browser_lang = currentLang;
|
||||
}
|
||||
|
||||
function showThemeNoticeToast(isEnabled) {
|
||||
document.getElementById('anomalous-theme-toast')?.remove();
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'anomalous-theme-toast';
|
||||
toast.className = 'anomalous-theme-toast' + (isEnabled ? ' is-abyssal' : '');
|
||||
toast.textContent = isEnabled ? t('themeDomainActivated') : t('themeDomainDeactivated');
|
||||
document.body.appendChild(toast);
|
||||
requestAnimationFrame(() => toast.classList.add('is-show'));
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('is-show');
|
||||
setTimeout(() => toast.remove(), 400);
|
||||
}, 2400);
|
||||
}
|
||||
|
||||
export function setAbyssalScarletTheme(enabled, notify = false) {
|
||||
const isEnabled = Boolean(enabled);
|
||||
localStorage.setItem('anomalous_theme_abyssal_scarlet', isEnabled ? 'true' : 'false');
|
||||
document.documentElement.classList.toggle('theme-abyssal-scarlet', isEnabled);
|
||||
document.getElementById('anomalous-modal')?.classList.toggle('theme-abyssal-scarlet', isEnabled);
|
||||
document.getElementById('anomalous-container')?.classList.toggle('theme-abyssal-scarlet', isEnabled);
|
||||
|
||||
try {
|
||||
const settings = app.extensionManager?.setting;
|
||||
if (settings && typeof settings.set === 'function' && settings.get(ABYSSAL_SCARLET_SETTING_ID) !== isEnabled) {
|
||||
settings.set(ABYSSAL_SCARLET_SETTING_ID, isEnabled);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (notify) showThemeNoticeToast(isEnabled);
|
||||
window.dispatchEvent(new CustomEvent('anomalous-theme-change', {
|
||||
detail: { theme: isEnabled ? 'abyssal-scarlet' : 'default', enabled: isEnabled }
|
||||
}));
|
||||
}
|
||||
|
||||
window.setAbyssalScarletTheme = setAbyssalScarletTheme;
|
||||
setAbyssalScarletTheme(localStorage.getItem('anomalous_theme_abyssal_scarlet') === 'true', false);
|
||||
|
||||
export function createInterfaceSettings() {
|
||||
const translations = getSettingTranslationPatches();
|
||||
return [
|
||||
{
|
||||
id: LANGUAGE_SETTING_ID,
|
||||
...translations[LANGUAGE_SETTING_ID],
|
||||
type: 'combo',
|
||||
defaultValue: () => normalizeLanguagePreference(localStorage.getItem('anomalous_lang')),
|
||||
onChange: applyLanguagePreference
|
||||
},
|
||||
{
|
||||
id: ABYSSAL_SCARLET_SETTING_ID,
|
||||
...translations[ABYSSAL_SCARLET_SETTING_ID],
|
||||
type: 'boolean',
|
||||
defaultValue: () => localStorage.getItem('anomalous_theme_abyssal_scarlet') === 'true',
|
||||
onChange(value) {
|
||||
setAbyssalScarletTheme(Boolean(value), true);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { translate as t } from './locales.js';
|
||||
import { text } from './ui_dom.js';
|
||||
import { materialNodeHeading } from './material_inspector.js';
|
||||
import { anomalousAlert } from './ui_dialog.js';
|
||||
|
||||
let activeDrag = null;
|
||||
|
||||
export function getCanvasPosition(event, canvas) {
|
||||
if (!canvas) return null;
|
||||
let position = null;
|
||||
if (canvas.convertEventToCanvasOffset) position = canvas.convertEventToCanvasOffset(event);
|
||||
else if (canvas.adjustMouseEvent) { canvas.adjustMouseEvent(event); position = [event.canvasX, event.canvasY]; }
|
||||
return position && position.every(Number.isFinite) ? position : null;
|
||||
}
|
||||
|
||||
export function materialDropNode(event, canvas, graph) {
|
||||
const surface = canvas?.canvas;
|
||||
if (!surface || !graph || canvas.graph !== graph) return null;
|
||||
const rect = surface.getBoundingClientRect();
|
||||
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) return null;
|
||||
// DOM text widgets overlay the canvas, so accept their drop surface too.
|
||||
if (event.target !== surface && !event.target?.closest?.('.dom-widget')) return null;
|
||||
const position = getCanvasPosition(event, canvas);
|
||||
if (!position) return null;
|
||||
const node = graph.getNodeOnPos?.(position[0], position[1]);
|
||||
return node && graph.getNodeById(node.id) === node ? node : null;
|
||||
}
|
||||
|
||||
/** Only this page's active drag can mutate a node; transfer data is never trusted. */
|
||||
export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropOnCanvas }) {
|
||||
element.draggable = true;
|
||||
element.addEventListener('dragstart', event => {
|
||||
if (event.target !== element && event.target?.closest?.('button, input, textarea, select')) { event.preventDefault(); return; }
|
||||
const data = payload();
|
||||
const graph = app.graph;
|
||||
const canvas = app.canvas;
|
||||
if (!data || !event.dataTransfer || !canvas?.canvas) { event.preventDefault(); return; }
|
||||
activeDrag?.();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.setData('application/x-anomalous-material', 'local-drag');
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
const defaultHint = data.dragHint || t('materialDropHint');
|
||||
const hint = text(document.body, 'div', defaultHint, 'anomalous-material-drag-hint');
|
||||
hint.setAttribute('role', 'status');
|
||||
const cleanup = () => {
|
||||
clearTimeout(reveal);
|
||||
owner.modal?.classList.remove('anomalous-material-dragging');
|
||||
hint.remove();
|
||||
for (const [name, fn] of listeners) window.removeEventListener(name, fn, true);
|
||||
if (activeDrag === cleanup) activeDrag = null;
|
||||
};
|
||||
const isOverCanvasSurface = event => {
|
||||
const surface = canvas?.canvas;
|
||||
if (!surface || app.graph !== graph || app.canvas !== canvas || canvas.graph !== graph) return false;
|
||||
if (event.target !== surface && !event.target?.closest?.('.dom-widget')) return false;
|
||||
const rect = surface.getBoundingClientRect();
|
||||
return event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom;
|
||||
};
|
||||
const target = event => app.graph === graph && app.canvas === canvas ? materialDropNode(event, canvas, graph) : null;
|
||||
const over = event => {
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
const node = target(event);
|
||||
const validNode = !!node && accepts?.(node, data);
|
||||
if (validNode) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
hint.classList.add('is-target-valid');
|
||||
hint.textContent = t('materialDropTarget', { name: materialNodeHeading(node) });
|
||||
} else if (dropOnCanvas && isOverCanvasSurface(event)) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
hint.classList.add('is-target-valid');
|
||||
hint.textContent = data.dragTargetHint || defaultHint;
|
||||
} else {
|
||||
event.dataTransfer.dropEffect = 'none';
|
||||
hint.classList.remove('is-target-valid');
|
||||
hint.textContent = defaultHint;
|
||||
}
|
||||
hint.style.left = `${Math.max(8, Math.min(event.clientX + 16, window.innerWidth - 250))}px`;
|
||||
hint.style.top = `${Math.max(8, event.clientY - 48)}px`;
|
||||
};
|
||||
const finish = async event => {
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
const node = target(event);
|
||||
const validNode = !!node && accepts?.(node, data);
|
||||
const overCanvas = isOverCanvasSurface(event);
|
||||
cleanup();
|
||||
if (validNode) {
|
||||
try { await drop(node, data, graph); }
|
||||
catch (error) { await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); }
|
||||
return;
|
||||
}
|
||||
if (dropOnCanvas && overCanvas) {
|
||||
const pos = getCanvasPosition(event, canvas);
|
||||
try { await dropOnCanvas(event, data, graph, pos); }
|
||||
catch (error) { await anomalousAlert(t(error.message) === error.message ? t('recipeOpenError') : t(error.message)); }
|
||||
return;
|
||||
}
|
||||
};
|
||||
const escape = event => { if (event.key === 'Escape') cleanup(); };
|
||||
const listeners = [['dragover', over], ['drop', finish], ['dragend', cleanup], ['keydown', escape], ['blur', cleanup]];
|
||||
// Let the browser capture the card's drag image before revealing the canvas.
|
||||
const reveal = setTimeout(() => owner.modal?.classList.add('anomalous-material-dragging'), 0);
|
||||
activeDrag = cleanup;
|
||||
for (const [name, fn] of listeners) window.addEventListener(name, fn, true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { translate as t } from './locales.js';
|
||||
import { anomalousAlert } from './ui_dialog.js';
|
||||
|
||||
/** Shared save receipt; navigation is owned by the browser instance. */
|
||||
export function showMaterialSaved(owner, material, beforeOpen) {
|
||||
document.querySelector('.anomalous-material-receipt')?.remove();
|
||||
const receipt = document.createElement('div');
|
||||
receipt.className = 'anomalous-material-receipt';
|
||||
receipt.setAttribute('role', 'status');
|
||||
const message = document.createElement('span');
|
||||
message.textContent = t('materialSavedToLibrary');
|
||||
receipt.appendChild(message);
|
||||
if (typeof owner?.openSavedMaterial === 'function' && material?.filename) {
|
||||
const view = document.createElement('button');
|
||||
view.type = 'button';
|
||||
view.textContent = t('materialViewSaved');
|
||||
view.onclick = async () => {
|
||||
view.disabled = true;
|
||||
try {
|
||||
beforeOpen?.();
|
||||
await owner.openSavedMaterial(material);
|
||||
receipt.remove();
|
||||
} catch (error) {
|
||||
await anomalousAlert(t('materialDetailLoadError'));
|
||||
view.disabled = false;
|
||||
}
|
||||
};
|
||||
receipt.appendChild(view);
|
||||
}
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.textContent = '×';
|
||||
close.setAttribute('aria-label', t('materialDismissReceipt'));
|
||||
close.onclick = () => receipt.remove();
|
||||
receipt.appendChild(close);
|
||||
document.body.appendChild(receipt);
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
import { text, jsonResponse } from './ui_dom.js';
|
||||
/** Shared image metadata and node-parameter inspection for the library and workbench. */
|
||||
import { translate } from './locales.js';
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
export function sectionLabel(parent, value) {
|
||||
return text(parent, 'div', value, 'anomalous-material-section-label');
|
||||
}
|
||||
|
||||
export function fileBaseName(value) {
|
||||
return String(value || '').replace(/\\/g, '/').split('/').pop();
|
||||
}
|
||||
|
||||
function previewIsVideo(url) {
|
||||
return /\.(?:mp4|webm)(?:$|\?|&|#)/i.test(url || '');
|
||||
}
|
||||
|
||||
export function modelCustomNotes(model) {
|
||||
return String(model?.metadata?.custom_notes || '').trim();
|
||||
}
|
||||
|
||||
function bindHoverPreviewVideo(video) {
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
video.onpointerenter = () => video.play().catch(() => {});
|
||||
video.onpointerleave = () => {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
};
|
||||
}
|
||||
|
||||
export function appendLocalPreview(parent, url, className) {
|
||||
if (!url) return null;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = className;
|
||||
if (previewIsVideo(url)) {
|
||||
const video = document.createElement('video');
|
||||
video.src = url;
|
||||
bindHoverPreviewVideo(video);
|
||||
wrap.appendChild(video);
|
||||
} else {
|
||||
const image = document.createElement('img');
|
||||
image.src = url;
|
||||
image.alt = '';
|
||||
image.loading = 'lazy';
|
||||
wrap.appendChild(image);
|
||||
}
|
||||
parent.appendChild(wrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export async function resolveLocalModels(paths) {
|
||||
const unique = [...new Set((paths || []).filter(Boolean))];
|
||||
if (!unique.length) return {};
|
||||
const response = await fetch('/anomalous/resolve_paths_to_previews', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: unique, exact_only: false }),
|
||||
});
|
||||
const payload = await jsonResponse(response, 'preview resolve failed');
|
||||
return payload.models || {};
|
||||
}
|
||||
|
||||
export function lookupLocalModel(localModels, path) {
|
||||
if (!path || !localModels) return null;
|
||||
return localModels[path] || localModels[fileBaseName(path)] || null;
|
||||
}
|
||||
|
||||
export async function parsePngMetadataFromUrl(url, options = {}) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const response = await fetch(url, { cache: 'force-cache', signal: options.signal });
|
||||
if (!response.ok) return null;
|
||||
const buffer = await response.arrayBuffer();
|
||||
const view = new DataView(buffer);
|
||||
if (view.byteLength < 32) return null;
|
||||
// PNG magic bytes: 137, 80, 78, 71, 13, 10, 26, 10
|
||||
if (view.getUint32(0) !== 0x89504E47 || view.getUint32(4) !== 0x0D0A1A0A) return null;
|
||||
|
||||
let offset = 8;
|
||||
const utf8 = new TextDecoder('utf-8');
|
||||
const latin1 = new TextDecoder('iso-8859-1');
|
||||
const result = {};
|
||||
|
||||
while (offset + 8 <= buffer.byteLength) {
|
||||
const length = view.getUint32(offset);
|
||||
const type = String.fromCharCode(
|
||||
view.getUint8(offset + 4),
|
||||
view.getUint8(offset + 5),
|
||||
view.getUint8(offset + 6),
|
||||
view.getUint8(offset + 7),
|
||||
);
|
||||
const dataOffset = offset + 8;
|
||||
if (type === 'IDAT') break; // Reached image raster data, text metadata is prior
|
||||
|
||||
if (dataOffset + length > buffer.byteLength) break;
|
||||
|
||||
if (type === 'tEXt') {
|
||||
const bytes = new Uint8Array(buffer, dataOffset, length);
|
||||
const nullIdx = bytes.indexOf(0);
|
||||
if (nullIdx > -1) {
|
||||
const key = latin1.decode(bytes.subarray(0, nullIdx));
|
||||
const val = latin1.decode(bytes.subarray(nullIdx + 1));
|
||||
try { result[key] = JSON.parse(val); } catch { result[key] = val; }
|
||||
}
|
||||
} else if (type === 'iTXt') {
|
||||
const bytes = new Uint8Array(buffer, dataOffset, length);
|
||||
const nullIdx = bytes.indexOf(0);
|
||||
if (nullIdx > -1) {
|
||||
const key = latin1.decode(bytes.subarray(0, nullIdx));
|
||||
let ptr = nullIdx + 1;
|
||||
const compFlag = bytes[ptr++];
|
||||
const compMethod = bytes[ptr++];
|
||||
while (ptr < bytes.length && bytes[ptr] !== 0) ptr++;
|
||||
ptr++;
|
||||
while (ptr < bytes.length && bytes[ptr] !== 0) ptr++;
|
||||
ptr++;
|
||||
if (compFlag === 0 && ptr <= bytes.length) {
|
||||
const val = utf8.decode(bytes.subarray(ptr));
|
||||
try { result[key] = JSON.parse(val); } catch { result[key] = val; }
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += 12 + length;
|
||||
}
|
||||
return result.workflow || result.prompt || null;
|
||||
} catch (e) {
|
||||
if (e?.name === 'AbortError') return null;
|
||||
console.warn('Client-side PNG metadata read skipped:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract generation parameters, full prompts, and categorized model references with details (e.g. LoRA strengths)
|
||||
* directly from the workflow.
|
||||
*/
|
||||
export function extractWorkflowDetails(workflow) {
|
||||
const params = {};
|
||||
const positivePrompts = [];
|
||||
const negativePrompts = [];
|
||||
const allPrompts = [];
|
||||
const loraDetailsMap = new Map();
|
||||
const discoveredModels = {
|
||||
checkpoints: [],
|
||||
loras: [],
|
||||
textEncoders: [],
|
||||
vaes: [],
|
||||
others: [],
|
||||
};
|
||||
|
||||
if (!workflow || typeof workflow !== 'object') {
|
||||
return { params, positivePrompts, negativePrompts, allPrompts, loraDetailsMap, discoveredModels };
|
||||
}
|
||||
|
||||
const nodes = Array.isArray(workflow.nodes) ? workflow.nodes : [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!node || typeof node !== 'object') continue;
|
||||
const ntype = String(node.type || '').trim();
|
||||
const ntypeLower = ntype.toLowerCase();
|
||||
const widgets = Array.isArray(node.widgets_values) ? node.widgets_values : [];
|
||||
|
||||
// 1. Sampling parameters
|
||||
if (ntypeLower === 'ksampler' || ntypeLower === 'ksampleradvanced') {
|
||||
const isAdv = ntypeLower === 'ksampleradvanced';
|
||||
const offset = isAdv ? 1 : 0;
|
||||
if (widgets[offset] != null && params.seed == null) params.seed = widgets[offset];
|
||||
if (widgets[2 + offset] != null && params.steps == null) params.steps = widgets[2 + offset];
|
||||
if (widgets[3 + offset] != null && params.cfg == null) params.cfg = widgets[3 + offset];
|
||||
if (widgets[4 + offset] != null && params.sampler_name == null) params.sampler_name = widgets[4 + offset];
|
||||
if (widgets[5 + offset] != null && params.scheduler == null) params.scheduler = widgets[5 + offset];
|
||||
if (widgets[6 + offset] != null && params.denoise == null) params.denoise = widgets[6 + offset];
|
||||
} else if (ntypeLower === 'emptylatentimage') {
|
||||
if (widgets[0] && widgets[1] && params.resolution == null) {
|
||||
params.resolution = `${widgets[0]} × ${widgets[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Full un-truncated prompts
|
||||
if (ntypeLower.includes('cliptextencode') || ntypeLower.includes('prompt')) {
|
||||
const nodeTitle = String(node.title || ntype).toLowerCase();
|
||||
for (const val of widgets) {
|
||||
if (typeof val === 'string' && val.trim()) {
|
||||
const textVal = val.trim();
|
||||
if (!allPrompts.includes(textVal)) allPrompts.push(textVal);
|
||||
if (nodeTitle.includes('neg') || nodeTitle.includes('负向')) {
|
||||
if (!negativePrompts.includes(textVal)) negativePrompts.push(textVal);
|
||||
} else {
|
||||
if (!positivePrompts.includes(textVal)) positivePrompts.push(textVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Categorized model references & LoRA weights
|
||||
if (ntypeLower.includes('checkpoint') || ntypeLower === 'unetloader' || ntypeLower.includes('diffusionmodel')) {
|
||||
if (widgets[0] && typeof widgets[0] === 'string') {
|
||||
const name = widgets[0];
|
||||
if (!discoveredModels.checkpoints.some(c => c.name === name)) {
|
||||
discoveredModels.checkpoints.push({
|
||||
name,
|
||||
category: ntypeLower.includes('unet') ? 'unet' : 'checkpoint',
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (ntypeLower.includes('lora')) {
|
||||
if (widgets[0] && typeof widgets[0] === 'string') {
|
||||
const name = widgets[0];
|
||||
const strengthModel = widgets[1] != null ? Number(widgets[1]) : 1.0;
|
||||
const strengthClip = widgets[2] != null ? Number(widgets[2]) : strengthModel;
|
||||
loraDetailsMap.set(name, { strengthModel, strengthClip });
|
||||
const baseName = name.replace(/\\/g, '/').split('/').pop();
|
||||
loraDetailsMap.set(baseName, { strengthModel, strengthClip });
|
||||
if (!discoveredModels.loras.some(l => l.name === name)) {
|
||||
discoveredModels.loras.push({ name, strengthModel, strengthClip, category: 'lora' });
|
||||
}
|
||||
}
|
||||
} else if (ntypeLower.includes('vaeloader') || (ntypeLower.includes('vae') && !ntypeLower.includes('encode') && !ntypeLower.includes('decode'))) {
|
||||
if (widgets[0] && typeof widgets[0] === 'string') {
|
||||
const name = widgets[0];
|
||||
if (!discoveredModels.vaes.some(v => v.name === name)) {
|
||||
discoveredModels.vaes.push({ name, category: 'vae' });
|
||||
}
|
||||
}
|
||||
} else if (ntypeLower.includes('cliploader') || ntypeLower.includes('dualcliploader') || ntypeLower.includes('textencoder')) {
|
||||
for (const w of widgets) {
|
||||
if (typeof w === 'string' && (w.endsWith('.safetensors') || w.endsWith('.bin') || w.endsWith('.pt') || w.endsWith('.sft'))) {
|
||||
if (!discoveredModels.textEncoders.some(t => t.name === w)) {
|
||||
discoveredModels.textEncoders.push({ name: w, category: 'text_encoder' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { params, positivePrompts, negativePrompts, allPrompts, loraDetailsMap, discoveredModels };
|
||||
}
|
||||
|
||||
export function detailedBlocksFromWorkflow(workflow, serverBlocks = []) {
|
||||
const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : [];
|
||||
if (!nodes.length) return Array.isArray(serverBlocks) ? serverBlocks : [];
|
||||
const serverById = new Map((serverBlocks || []).map(block => [String(block?.node_id), block]));
|
||||
const occurrences = {};
|
||||
return nodes.filter(node => node && typeof node === 'object' && node.type).map(node => {
|
||||
const nodeType = String(node.type);
|
||||
occurrences[nodeType] = (occurrences[nodeType] || 0) + 1;
|
||||
const server = serverById.get(String(node.id)) || {};
|
||||
const lower = nodeType.toLowerCase();
|
||||
const volatile = lower === 'ksampler' ? [0] : lower === 'ksampleradvanced' ? [1] : [];
|
||||
return {
|
||||
node_id: node.id,
|
||||
type: nodeType,
|
||||
title: node.title || server.title || nodeType,
|
||||
occurrence: occurrences[nodeType],
|
||||
widget_count: Array.isArray(node.widgets_values) ? node.widgets_values.length : 0,
|
||||
widgets_values: Array.isArray(node.widgets_values) ? node.widgets_values : [],
|
||||
volatile_widget_indexes: Array.isArray(server.volatile_widget_indexes)
|
||||
? server.volatile_widget_indexes
|
||||
: volatile,
|
||||
properties: node.properties && typeof node.properties === 'object' ? node.properties : {},
|
||||
mode: node.mode,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function materialWidgetLabels(nodeType) {
|
||||
const type = String(nodeType || '').toLowerCase();
|
||||
if (type === 'ksampler') return [
|
||||
t('materialParamSeed'), t('materialParamSeedControl'), t('recipeCardSpecsSteps'),
|
||||
'CFG', t('recipeCardSpecsSampler'), t('materialScheduler'), t('materialDenoise'),
|
||||
];
|
||||
if (type === 'ksampleradvanced') return [
|
||||
t('materialParamAddNoise'), t('materialParamSeed'), t('materialParamSeedControl'),
|
||||
t('recipeCardSpecsSteps'), 'CFG', t('recipeCardSpecsSampler'), t('materialScheduler'),
|
||||
t('materialParamStartStep'), t('materialParamEndStep'), t('materialParamLeftoverNoise'),
|
||||
];
|
||||
if (type === 'emptylatentimage') return [t('materialParamWidth'), t('materialParamHeight'), t('materialParamBatchSize')];
|
||||
if (/checkpointloader(simple)?$/.test(type)) return [t('materialParamCheckpoint')];
|
||||
if (type.endsWith('unetloader')) return [t('materialParamUnet'), t('materialParamWeightDtype')];
|
||||
if (type.includes('loraloader')) return [t('materialParamLora'), t('materialParamModelStrength'), t('materialParamClipStrength')];
|
||||
if (type.endsWith('vaeloader')) return [t('materialParamVae')];
|
||||
if (type.includes('cliptextencode')) return [t('materialPromptText')];
|
||||
if (type.endsWith('clipvisionloader')) return [t('materialParamClipVision')];
|
||||
if (type.endsWith('controlnetloader')) return [t('materialParamControlNet')];
|
||||
if (type.endsWith('dualcliploader')) return [t('materialParamClipOne'), t('materialParamClipTwo'), t('materialParamClipType')];
|
||||
if (type.endsWith('triplecliploader')) return [t('materialParamClipOne'), t('materialParamClipTwo'), t('materialParamClipThree')];
|
||||
if (type.endsWith('cliploader')) return [t('materialParamClipOne'), t('materialParamClipType')];
|
||||
if (type === 'saveimage') return [t('materialParamFilenamePrefix')];
|
||||
return [];
|
||||
}
|
||||
|
||||
const comfyWidgetLabelCache = new Map();
|
||||
|
||||
function comfyNodeTitle(nodeType) {
|
||||
const ctor = globalThis.LiteGraph?.registered_node_types?.[nodeType];
|
||||
return (ctor && ctor.title) || nodeType;
|
||||
}
|
||||
|
||||
function comfyWidgetLabels(nodeType) {
|
||||
if (comfyWidgetLabelCache.has(nodeType)) return comfyWidgetLabelCache.get(nodeType);
|
||||
let labels = [];
|
||||
try {
|
||||
const node = globalThis.LiteGraph?.createNode?.(nodeType);
|
||||
if (node?.widgets?.length) labels = node.widgets.map(widget => widget.label || widget.name || '');
|
||||
} catch (error) { /* some node types cannot be instantiated off-canvas */ }
|
||||
if (!labels.length) labels = materialWidgetLabels(nodeType);
|
||||
comfyWidgetLabelCache.set(nodeType, labels);
|
||||
return labels;
|
||||
}
|
||||
|
||||
const NODE_TITLE_SUFFIX = /\s*(\((?:negative|positive|neg|pos)\)|((?:负向|正向|反向))|#\d+)\s*$/i;
|
||||
|
||||
function compactNodeToken(value) {
|
||||
return String(value || '').toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, '');
|
||||
}
|
||||
|
||||
function comfyNodeDefaultTitles(nodeType) {
|
||||
const ctor = globalThis.LiteGraph?.registered_node_types?.[nodeType];
|
||||
const data = ctor?.nodeData;
|
||||
return [nodeType, ctor?.title, ctor?.comfyClass, data?.name, data?.display_name].filter(Boolean);
|
||||
}
|
||||
|
||||
export function materialNodeHeading(block) {
|
||||
const type = String(block?.type || '');
|
||||
const saved = String(block?.title || '').trim();
|
||||
const localized = comfyNodeTitle(type) || type;
|
||||
const suffixMatch = saved.match(NODE_TITLE_SUFFIX);
|
||||
const suffix = suffixMatch ? ` ${suffixMatch[1]}` : '';
|
||||
if (!saved || saved === type) return localized + suffix;
|
||||
|
||||
const core = saved.replace(NODE_TITLE_SUFFIX, '').trim();
|
||||
const compactCore = compactNodeToken(core);
|
||||
const isDefault = [type, localized, ...comfyNodeDefaultTitles(type)].some(name => {
|
||||
const compactName = compactNodeToken(name);
|
||||
return core === name || compactCore === compactName || (compactName && compactCore.startsWith(compactName));
|
||||
});
|
||||
return isDefault ? localized + suffix : saved;
|
||||
}
|
||||
|
||||
function formatMaterialParameterValue(value) {
|
||||
if (value === null) return 'null';
|
||||
if (value === undefined) return 'undefined';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
try { return JSON.stringify(value, null, 2); } catch (error) { return String(value); }
|
||||
}
|
||||
|
||||
function isPromptBlock(block) {
|
||||
const type = String(block?.type || '').toLowerCase();
|
||||
return (type.includes('cliptextencode') || type.includes('prompt')) &&
|
||||
Array.isArray(block.widgets_values) &&
|
||||
block.widgets_values.length > 0 &&
|
||||
typeof block.widgets_values[0] === 'string';
|
||||
}
|
||||
|
||||
function promptRoleLabel(role) {
|
||||
return t({
|
||||
positive: 'recipePromptRolePositive',
|
||||
negative: 'recipePromptRoleNegative',
|
||||
both: 'recipePromptRoleBoth',
|
||||
ignored: 'recipePromptRoleIgnored',
|
||||
unknown: 'recipePromptRoleUnknown',
|
||||
}[role] || 'recipePromptRoleUnknown');
|
||||
}
|
||||
|
||||
export function applyPromptRolesToBlocks(blocks, promptRoles) {
|
||||
if (!Array.isArray(blocks) || !promptRoles || typeof promptRoles !== 'object') return blocks;
|
||||
for (const block of blocks) {
|
||||
const info = promptRoles[String(block?.node_id)];
|
||||
if (!info || typeof info !== 'object') continue;
|
||||
block.promptRole = info.role;
|
||||
block.promptRoleSource = info.source;
|
||||
block.promptRoleManual = info.source === 'manual';
|
||||
block.promptRoleAutomatic = info.automatic_role || (info.source === 'manual' ? 'unknown' : info.role);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function mergePromptRoleOverrides(promptRoles, overrides) {
|
||||
const roles = { ...(promptRoles || {}) };
|
||||
if (!overrides || typeof overrides !== 'object') return roles;
|
||||
for (const [nodeId, entry] of Object.entries(overrides)) {
|
||||
if (entry?.role) {
|
||||
const automatic = roles[String(nodeId)];
|
||||
roles[String(nodeId)] = {
|
||||
role: entry.role,
|
||||
source: 'manual',
|
||||
automatic_role: automatic?.automatic_role || automatic?.role || 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
export function promptGroupsFromBlocks(blocks) {
|
||||
const positive = [];
|
||||
const negative = [];
|
||||
for (const block of Array.isArray(blocks) ? blocks : []) {
|
||||
const value = (block.widgets_values || []).find(item => typeof item === 'string' && item.trim());
|
||||
if (!value) continue;
|
||||
const textVal = value.trim();
|
||||
if ((block.promptRole === 'positive' || block.promptRole === 'both') && !positive.includes(textVal)) positive.push(textVal);
|
||||
if ((block.promptRole === 'negative' || block.promptRole === 'both') && !negative.includes(textVal)) negative.push(textVal);
|
||||
}
|
||||
return { positive, negative };
|
||||
}
|
||||
|
||||
export function renderMaterialPromptGroups(parent, groups, options = {}) {
|
||||
const positive = Array.isArray(groups?.positive) ? groups.positive.filter(Boolean) : [];
|
||||
const negative = Array.isArray(groups?.negative) ? groups.negative.filter(Boolean) : [];
|
||||
if (!positive.length && !negative.length) return false;
|
||||
|
||||
const renderCard = (titleText, promptStr, tone) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = tone ? `anomalous-material-prompt-card is-${tone}` : 'anomalous-material-prompt-card';
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'anomalous-material-prompt-bar';
|
||||
text(bar, 'span', titleText, 'anomalous-material-prompt-bar-label');
|
||||
const copyBtn = text(bar, 'button', t('materialCopyPrompt'), 'anomalous-material-mini-btn');
|
||||
copyBtn.type = 'button';
|
||||
bindCopyButton(copyBtn, () => promptStr, 'materialCopied');
|
||||
card.appendChild(bar);
|
||||
text(card, 'div', promptStr, 'anomalous-material-prompt-content is-expanded');
|
||||
parent.appendChild(card);
|
||||
};
|
||||
|
||||
if (positive.length) renderCard(t('materialPositivePrompt'), positive.join('\n\n'), 'positive');
|
||||
if (negative.length) renderCard(t('materialNegativePrompt'), negative.join('\n\n'), 'negative');
|
||||
if (options.manual) text(parent, 'p', t('materialPromptRoleFromRecipe'), 'anomalous-material-muted');
|
||||
return true;
|
||||
}
|
||||
|
||||
function bindCopyButton(button, value, successKey) {
|
||||
const label = button.textContent;
|
||||
button.onclick = async event => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(value());
|
||||
button.textContent = t(successKey);
|
||||
} catch (error) {
|
||||
button.textContent = t('materialCopyError');
|
||||
}
|
||||
setTimeout(() => { if (button.isConnected) button.textContent = label; }, 1200);
|
||||
};
|
||||
}
|
||||
|
||||
function renderPromptBlock(content, block, widgetValues) {
|
||||
const promptWrap = document.createElement('div');
|
||||
promptWrap.className = 'anomalous-material-prompt-station';
|
||||
|
||||
const promptText = String(widgetValues[0] ?? '');
|
||||
const charCount = promptText.length;
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'anomalous-material-prompt-bar';
|
||||
|
||||
const titleSec = document.createElement('div');
|
||||
titleSec.className = 'anomalous-material-prompt-title-sec';
|
||||
text(titleSec, 'span', `💬 ${t('materialPromptNode') || '提示词'}`, 'anomalous-material-prompt-title');
|
||||
text(titleSec, 'span', t('materialChars', { count: charCount }), 'anomalous-material-prompt-count');
|
||||
bar.appendChild(titleSec);
|
||||
|
||||
const copyPromptBtn = text(bar, 'button', t('materialCopyPromptText'), 'anomalous-material-copy-prompt-btn');
|
||||
copyPromptBtn.type = 'button';
|
||||
bindCopyButton(copyPromptBtn, () => promptText, 'materialPromptCopied');
|
||||
promptWrap.appendChild(bar);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'anomalous-material-prompt-body';
|
||||
body.textContent = promptText || t('materialNoWidgetParameters');
|
||||
promptWrap.appendChild(body);
|
||||
|
||||
content.appendChild(promptWrap);
|
||||
}
|
||||
|
||||
function renderNodeParameterRows(content, block, widgetValues, offset = 0) {
|
||||
const labels = comfyWidgetLabels(block.type);
|
||||
const volatileIndexes = new Set(Array.isArray(block.volatile_widget_indexes) ? block.volatile_widget_indexes : []);
|
||||
if (!widgetValues.length) {
|
||||
text(content, 'p', t('materialNoWidgetParameters'), 'anomalous-material-muted');
|
||||
return;
|
||||
}
|
||||
widgetValues.forEach((value, localIndex) => {
|
||||
const index = localIndex + offset;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'anomalous-material-parameter-row';
|
||||
const labelWrap = document.createElement('div');
|
||||
labelWrap.className = 'anomalous-material-parameter-label';
|
||||
text(labelWrap, 'span', labels[index] || t('materialWidgetIndex', { index: index + 1 }));
|
||||
text(labelWrap, 'code', `#${index}`);
|
||||
if (volatileIndexes.has(index)) {
|
||||
text(labelWrap, 'span', t('materialVolatileParameter'), 'anomalous-material-volatile-badge');
|
||||
}
|
||||
const valueText = text(row, 'pre', formatMaterialParameterValue(value), 'anomalous-material-parameter-value');
|
||||
valueText.title = t('materialParameterFullValue');
|
||||
row.prepend(labelWrap);
|
||||
content.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderNodeProperties(content, properties) {
|
||||
const props = properties && typeof properties === 'object' ? properties : {};
|
||||
if (!Object.keys(props).length) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'anomalous-material-parameter-row';
|
||||
const labelWrap = document.createElement('div');
|
||||
labelWrap.className = 'anomalous-material-parameter-label';
|
||||
text(labelWrap, 'span', t('materialNodeProperties'));
|
||||
const propertyValue = document.createElement('pre');
|
||||
propertyValue.className = 'anomalous-material-parameter-value';
|
||||
propertyValue.textContent = formatMaterialParameterValue(props);
|
||||
row.append(labelWrap, propertyValue);
|
||||
content.appendChild(row);
|
||||
}
|
||||
|
||||
function renderNodeCardContent(node, block, widgetValues) {
|
||||
const content = document.createElement('div');
|
||||
content.className = 'anomalous-material-node-parameter-content';
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'anomalous-material-node-meta';
|
||||
text(meta, 'span', `${t('materialNodeId')}: ${block.node_id ?? '—'}`);
|
||||
if (block.mode != null) text(meta, 'span', `${t('materialNodeMode')}: ${block.mode}`);
|
||||
|
||||
const copy = text(meta, 'button', t('materialCopyNodeParameters'), 'anomalous-material-mini-btn');
|
||||
copy.type = 'button';
|
||||
bindCopyButton(copy, () => JSON.stringify({
|
||||
node_id: block.node_id, type: block.type, title: block.title,
|
||||
widgets_values: widgetValues, properties: block.properties || {}, mode: block.mode,
|
||||
}, null, 2), 'materialCopied');
|
||||
const technical = document.createElement('details');
|
||||
technical.className = 'anomalous-notebook-fold';
|
||||
text(technical, 'summary', t('recipeAdvancedInfo'));
|
||||
technical.appendChild(meta);
|
||||
|
||||
if (isPromptBlock(block)) {
|
||||
renderPromptBlock(content, block, widgetValues);
|
||||
if (widgetValues.length > 1) {
|
||||
renderNodeParameterRows(content, block, widgetValues.slice(1), 1);
|
||||
}
|
||||
} else {
|
||||
renderNodeParameterRows(content, block, widgetValues);
|
||||
}
|
||||
|
||||
renderNodeProperties(technical, block.properties);
|
||||
content.appendChild(technical);
|
||||
node.appendChild(content);
|
||||
}
|
||||
|
||||
function renderSingleNodeCard(block, options = {}) {
|
||||
const node = document.createElement('details');
|
||||
node.className = 'anomalous-material-node-detail';
|
||||
const widgetValues = Array.isArray(block.widgets_values) ? block.widgets_values : [];
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
if (options.selectable) {
|
||||
const select = document.createElement('input');
|
||||
select.type = 'checkbox';
|
||||
select.className = 'anomalous-material-node-select';
|
||||
select.dataset.nodeId = String(block.node_id);
|
||||
select.checked = options.selectedIds?.has(String(block.node_id)) || false;
|
||||
select.title = t('materialSelectNodeForSaving');
|
||||
select.setAttribute('aria-label', t('materialSelectNodeForSaving'));
|
||||
select.addEventListener('click', event => event.stopPropagation());
|
||||
select.addEventListener('change', () => {
|
||||
options.onSelectionChange?.(block, select.checked);
|
||||
node.classList.toggle('is-selected', select.checked);
|
||||
});
|
||||
node.classList.toggle('is-selected', select.checked);
|
||||
summary.appendChild(select);
|
||||
}
|
||||
|
||||
const heading = document.createElement('span');
|
||||
heading.className = 'anomalous-material-node-heading';
|
||||
text(heading, 'strong', materialNodeHeading(block));
|
||||
const renderRoleBadge = () => {
|
||||
heading.querySelector('.anomalous-recipe-prompt-role-badge')?.remove();
|
||||
if (!block.promptRole) return;
|
||||
const badge = text(heading, 'span', promptRoleLabel(block.promptRole), `anomalous-recipe-prompt-role-badge is-${block.promptRole}`);
|
||||
badge.title = block.promptRoleManual ? t('recipePromptRoleManual') : t('recipePromptRoleAutomatic');
|
||||
};
|
||||
renderRoleBadge();
|
||||
summary.appendChild(heading);
|
||||
|
||||
text(summary, 'span', t('materialParameterCount', { count: widgetValues.length }), 'anomalous-material-node-count');
|
||||
|
||||
if (isPromptBlock(block) && typeof options.onPromptRoleChange === 'function') {
|
||||
const roleSelect = document.createElement('select');
|
||||
roleSelect.className = 'anomalous-recipe-prompt-role-select anomalous-material-prompt-role-select';
|
||||
roleSelect.setAttribute('aria-label', t('recipePromptRoleChoose'));
|
||||
const automaticRole = block.promptRoleAutomatic || (block.promptRoleManual ? 'unknown' : block.promptRole) || 'unknown';
|
||||
const choices = [
|
||||
['auto', `${t('recipePromptRoleAutomatic')} · ${promptRoleLabel(automaticRole)}`],
|
||||
['positive', t('recipePromptRolePositive')],
|
||||
['negative', t('recipePromptRoleNegative')],
|
||||
['both', t('recipePromptRoleBoth')],
|
||||
['unknown', t('recipePromptRoleUnknown')],
|
||||
['ignored', t('recipePromptRoleIgnored')],
|
||||
];
|
||||
for (const [value, label] of choices) {
|
||||
const option = text(roleSelect, 'option', label);
|
||||
option.value = value;
|
||||
}
|
||||
roleSelect.value = block.promptRoleManual ? block.promptRole : 'auto';
|
||||
roleSelect.addEventListener('click', event => event.stopPropagation());
|
||||
roleSelect.addEventListener('change', async event => {
|
||||
event.stopPropagation();
|
||||
const previous = block.promptRoleManual ? block.promptRole : 'auto';
|
||||
roleSelect.disabled = true;
|
||||
try {
|
||||
await options.onPromptRoleChange(block, roleSelect.value, roleSelect);
|
||||
renderRoleBadge();
|
||||
} catch (error) {
|
||||
roleSelect.value = previous;
|
||||
console.error('Could not update prompt role:', error);
|
||||
} finally {
|
||||
if (roleSelect.isConnected) roleSelect.disabled = false;
|
||||
}
|
||||
});
|
||||
roleSelect.hidden = true;
|
||||
const adjust = text(summary, 'button', t('promptAdjustRole'), 'anomalous-btn-ghost');
|
||||
adjust.type = 'button';
|
||||
adjust.setAttribute('aria-expanded', 'false');
|
||||
adjust.onclick = event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
roleSelect.hidden = !roleSelect.hidden;
|
||||
adjust.setAttribute('aria-expanded', String(!roleSelect.hidden));
|
||||
if (!roleSelect.hidden) roleSelect.focus();
|
||||
};
|
||||
summary.appendChild(roleSelect);
|
||||
}
|
||||
|
||||
if (typeof options.onSaveBlock === 'function') {
|
||||
const save = text(summary, 'button', t(isPromptBlock(block) ? 'materialSavePromptAction' : 'materialSaveNode'), 'anomalous-material-node-save');
|
||||
save.type = 'button';
|
||||
save.title = t('materialSaveNodeHint');
|
||||
save.addEventListener('click', event => event.stopPropagation());
|
||||
save.addEventListener('click', () => options.onSaveBlock(block, save));
|
||||
}
|
||||
node.appendChild(summary);
|
||||
|
||||
const shouldOpen = !options.selectable && isPromptBlock(block);
|
||||
let rendered = false;
|
||||
|
||||
const renderContentIfNeeded = () => {
|
||||
if (rendered) return;
|
||||
rendered = true;
|
||||
renderNodeCardContent(node, block, widgetValues);
|
||||
};
|
||||
|
||||
if (shouldOpen) {
|
||||
node.open = true;
|
||||
renderContentIfNeeded();
|
||||
}
|
||||
|
||||
node.addEventListener('toggle', () => {
|
||||
if (node.open) renderContentIfNeeded();
|
||||
});
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export function renderDetailedNodeCards(parent, blocks, options = {}) {
|
||||
const list = document.createElement('div');
|
||||
list.className = 'anomalous-material-node-list';
|
||||
blocks.forEach(block => {
|
||||
list.appendChild(renderSingleNodeCard(block, options));
|
||||
});
|
||||
parent.appendChild(list);
|
||||
return list;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { composePromptPlan } from './prompt_composition.js';
|
||||
import { jsonResponse } from './ui_dom.js';
|
||||
|
||||
|
||||
export function materialPromptText(payload) {
|
||||
const data = payload.data || {};
|
||||
if (data.kind === 'prompt_plan') return composePromptPlan(data.plan || {});
|
||||
if (['prompt_text', 'prompt_note_bundle'].includes(data.kind)) {
|
||||
return { positive: data.note?.promptEn || '', negative: '' };
|
||||
}
|
||||
const groups = payload.prompt_groups || {};
|
||||
return Object.fromEntries(['positive', 'negative'].map(role => [role,
|
||||
(Array.isArray(groups[role]) ? groups[role] : []).filter(value => typeof value === 'string' && value.trim()).join('\n'),
|
||||
]));
|
||||
}
|
||||
|
||||
export async function loadMaterialPrompts(filename, signal) {
|
||||
const response = await fetch(`/anomalous/material_full?include_workflow=0&filename=${encodeURIComponent(filename)}`, { signal });
|
||||
const payload = await jsonResponse(response, 'material load failed');
|
||||
if (payload.status !== 'success') throw new Error('materialDetailLoadError');
|
||||
return materialPromptText(payload);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
const MODEL_FILE_PATTERN = /\.(safetensors|ckpt|pt|bin|pth|sft)$/i;
|
||||
|
||||
export function inferPickerModelType(node, widget, options = {}) {
|
||||
const widgetName = String(widget?.name || '').toLowerCase();
|
||||
const nodeType = String(node?.type || '').toLowerCase();
|
||||
const explicitLabel = String(options.modelTypeLabel || '');
|
||||
const key = `${widgetName} ${nodeType} ${explicitLabel.toLowerCase()}`;
|
||||
if (widgetName.includes('vae') || nodeType === 'vaeloader' || key.includes('vae loader')) {
|
||||
return { label: 'VAE', folderTypes: ['vae'], isLora: false };
|
||||
}
|
||||
if (widgetName.includes('lora') || key.includes('lora')) {
|
||||
return { label: 'LoRA', folderTypes: ['loras'], isLora: true };
|
||||
}
|
||||
if (widgetName.includes('control_net') || widgetName.includes('controlnet') || key.includes('controlnet')) {
|
||||
return { label: 'ControlNet', folderTypes: ['controlnet'], isLora: false };
|
||||
}
|
||||
if (widgetName.includes('ckpt') || widgetName.includes('checkpoint') || key.includes('checkpoint')) {
|
||||
return { label: 'Checkpoint', folderTypes: ['checkpoints'], isLora: false };
|
||||
}
|
||||
if (widgetName.includes('unet') || widgetName.includes('diffusion_model') || key.includes('unet loader')) {
|
||||
return { label: 'UNET', folderTypes: ['diffusion_models', 'unet'], isLora: false };
|
||||
}
|
||||
return {
|
||||
label: explicitLabel || node?.type || widget?.name || 'Model',
|
||||
folderTypes: [],
|
||||
isLora: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBaseModelFamily(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
if (!normalized) return '';
|
||||
if (normalized.includes('illustrious')) return 'illustrious';
|
||||
if (normalized.includes('pony')) return 'pony';
|
||||
if (normalized.includes('flux')) return 'flux';
|
||||
if (normalized.includes('sdxl') || normalized.includes('stablediffusionxl')) return 'sdxl';
|
||||
if (normalized.includes('sd35') || normalized.includes('stablediffusion35')) return 'sd35';
|
||||
if (normalized.includes('sd3') || normalized.includes('stablediffusion3')) return 'sd3';
|
||||
if (normalized.includes('sd21') || normalized.includes('stablediffusion21')) return 'sd21';
|
||||
if (normalized.includes('sd15') || normalized.includes('stablediffusion15')) return 'sd15';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getGraphNodeById(graph, nodeId) {
|
||||
return graph?.getNodeById?.(nodeId)
|
||||
|| graph?._nodes_by_id?.[nodeId]
|
||||
|| graph?._nodes?.find?.(candidate => candidate?.id === nodeId)
|
||||
|| null;
|
||||
}
|
||||
|
||||
export function collectMainModelContextRequests(graph, startNode) {
|
||||
if (!graph || !startNode) return [];
|
||||
const queue = [startNode];
|
||||
const visited = new Set();
|
||||
let depth = 0;
|
||||
while (queue.length && depth < 12) {
|
||||
const levelSize = queue.length;
|
||||
const found = [];
|
||||
for (let index = 0; index < levelSize; index += 1) {
|
||||
const current = queue.shift();
|
||||
if (!current || visited.has(current.id)) continue;
|
||||
visited.add(current.id);
|
||||
for (const widget of current.widgets || []) {
|
||||
if (typeof widget?.value !== 'string' || !MODEL_FILE_PATTERN.test(widget.value)) continue;
|
||||
const inferred = inferPickerModelType(current, widget);
|
||||
if (!inferred.isLora) {
|
||||
const request = { path: widget.value };
|
||||
if (inferred.folderTypes.length) request.folder_types = inferred.folderTypes;
|
||||
found.push(request);
|
||||
}
|
||||
}
|
||||
for (const input of current.inputs || []) {
|
||||
if (!['MODEL', 'CLIP'].includes(input?.type) || input.link == null) continue;
|
||||
const link = graph.links?.[input.link] || graph._links?.[input.link];
|
||||
const origin = getGraphNodeById(graph, link?.origin_id);
|
||||
if (origin && !visited.has(origin.id)) queue.push(origin);
|
||||
}
|
||||
}
|
||||
if (found.length) {
|
||||
const seen = new Set();
|
||||
return found.filter(item => {
|
||||
const key = `${(item.folder_types || []).join(',')}|${item.path}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function formatModelTypeLabel(type, fallback = 'Model') {
|
||||
const labels = {
|
||||
loras: 'LoRA',
|
||||
checkpoints: 'Checkpoint',
|
||||
diffusion_models: 'UNET',
|
||||
unet: 'UNET',
|
||||
controlnet: 'ControlNet',
|
||||
vae: 'VAE',
|
||||
};
|
||||
return labels[String(type || '').toLowerCase()] || fallback;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
const PHYSICAL_RENAME_PROTECTED_TYPES = new Set([
|
||||
'vae',
|
||||
'vae_approx',
|
||||
'clip',
|
||||
'text_encoders',
|
||||
'clip_vision',
|
||||
]);
|
||||
|
||||
// Scanner coverage is separate. These categories may be scanned, but automatic
|
||||
// Model Doctor redirection requires workflow-carried cryptographic identity.
|
||||
const HASH_ONLY_RECOVERY_TYPES = new Set([
|
||||
'vae',
|
||||
'vae_approx',
|
||||
'clip',
|
||||
'text_encoders',
|
||||
'clip_vision',
|
||||
]);
|
||||
|
||||
export function isPhysicalRenameProtectedType(folderType) {
|
||||
return PHYSICAL_RENAME_PROTECTED_TYPES.has(String(folderType || '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function inferModelFolderTypes(node, widget) {
|
||||
const widgetName = String(widget?.name || '').toLowerCase();
|
||||
const nodeType = String(node?.type || '').toLowerCase();
|
||||
const key = `${widgetName} ${nodeType}`;
|
||||
|
||||
if (key.includes('clip_vision') || key.includes('clip vision') || nodeType.includes('clipvision')) return ['clip_vision'];
|
||||
if (key.includes('vae_approx') || key.includes('vae approx')) return ['vae_approx'];
|
||||
if (widgetName.includes('vae') || nodeType === 'vaeloader' || key.includes('vae loader')) return ['vae'];
|
||||
if (widgetName.includes('lora') || key.includes('lora')) return ['loras'];
|
||||
if (widgetName.includes('control_net') || widgetName.includes('controlnet') || key.includes('controlnet')) return ['controlnet'];
|
||||
if (widgetName.includes('ckpt') || widgetName.includes('checkpoint') || key.includes('checkpoint')) return ['checkpoints'];
|
||||
if (widgetName.includes('unet') || widgetName.includes('diffusion_model') || key.includes('unet loader')) return ['diffusion_models', 'unet'];
|
||||
if (widgetName.includes('clip_name') || widgetName.includes('text_encoder') || nodeType.includes('cliploader')) return ['clip', 'text_encoders'];
|
||||
return [];
|
||||
}
|
||||
|
||||
export function requiresHashForModelRecovery(node, widget) {
|
||||
const types = inferModelFolderTypes(node, widget);
|
||||
return types.length > 0 && types.every(type => HASH_ONLY_RECOVERY_TYPES.has(type));
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { isPhysicalRenameProtectedType } from './model_policies.js';
|
||||
|
||||
export function usableSourceUrl(value) {
|
||||
const url = typeof value === 'string' ? value.trim() : '';
|
||||
if (!url) return '';
|
||||
const civitaiModel = url.match(/^https?:\/\/(?:www\.)?civitai\.(?:com|red)\/models\/(-?\d+)(?:[/?#]|$)/i);
|
||||
if (civitaiModel && Number(civitaiModel[1]) <= 0) return '';
|
||||
return url;
|
||||
}
|
||||
|
||||
export function foundationModelType(model) {
|
||||
const candidates = Array.isArray(model?.folderTypes) ? [...model.folderTypes] : [];
|
||||
if (model?.type) candidates.push(model.type);
|
||||
return candidates.find(isPhysicalRenameProtectedType) || '';
|
||||
}
|
||||
|
||||
export function shapeLibrarySourceModels(rawList, detectPlatform) {
|
||||
return (Array.isArray(rawList) ? rawList : []).map(m => {
|
||||
const meta = m.metadata || {};
|
||||
const sourceUrl = usableSourceUrl(meta.source_url);
|
||||
const civitaiUrl = usableSourceUrl(meta.civitai_url);
|
||||
const url = sourceUrl || civitaiUrl;
|
||||
const relPath = m.subfolder ? `${m.subfolder}/${m.filename}` : m.filename;
|
||||
return {
|
||||
key: `lib_${m.type}_${m.path_idx}_${relPath}`,
|
||||
type: m.type,
|
||||
path_idx: m.path_idx,
|
||||
subfolder: m.subfolder || '',
|
||||
filename: m.filename,
|
||||
basename: m.filename,
|
||||
relPath,
|
||||
size_mb: m.size_mb || 0,
|
||||
hash: meta.hash || '',
|
||||
civitai_url: civitaiUrl,
|
||||
source_url: sourceUrl,
|
||||
url,
|
||||
initialUrl: url,
|
||||
platform: detectPlatform(url),
|
||||
hasResolved: Boolean(url.trim()),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function matchesSearch(model, keyword) {
|
||||
if (!keyword) return true;
|
||||
const haystack = `${model.nodeTitle || ''} ${model.nodeType || ''} ${model.type || ''} ${model.filename || ''} ${model.basename || ''}`.toLowerCase();
|
||||
return haystack.includes(keyword);
|
||||
}
|
||||
|
||||
export function partitionSourceModels(models, filter = 'all', searchKeyword = '') {
|
||||
const allModels = Array.isArray(models) ? models : [];
|
||||
const allComponents = allModels.filter(model => Boolean(foundationModelType(model)));
|
||||
const allMain = allModels.filter(model => !foundationModelType(model));
|
||||
const matchesFilter = model => {
|
||||
if (filter === 'resolved' && !model.url) return false;
|
||||
if (filter === 'unresolved' && model.url) return false;
|
||||
return matchesSearch(model, searchKeyword);
|
||||
};
|
||||
const mainModels = allMain.filter(matchesFilter);
|
||||
const componentModels = allComponents.filter(matchesFilter);
|
||||
return {
|
||||
mainModels,
|
||||
componentModels,
|
||||
allComponentCount: componentModels.length,
|
||||
componentMissingCount: componentModels.filter(model => model.isMissing === true).length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Shared by Node Assistant and Material Library. No node creation or link edits.
|
||||
const clone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
export function selectedMaterialNode(app) {
|
||||
const nodes = Object.values(app.canvas?.selected_nodes || {});
|
||||
if (nodes.length !== 1 || !nodes[0]) return null;
|
||||
const node = nodes[0];
|
||||
const graph = app.graph?.getNodeById(node.id) === node ? app.graph : (node.graph || app.canvas?.graph || app.graph);
|
||||
return graph?.getNodeById(node.id) === node ? node : null;
|
||||
}
|
||||
|
||||
function volatile(node, widget, index) {
|
||||
return /(^|[_\s-])(seed|noise_seed|random_seed|variation_seed|last_seed)([_\s-]|$)/i.test(widget?.name || '')
|
||||
|| (node.type === 'KSampler' && index === 0) || (node.type === 'KSamplerAdvanced' && index === 1);
|
||||
}
|
||||
|
||||
function hashesFor(graph, id) {
|
||||
return Object.fromEntries(Object.entries(graph.extra?.anomalous_hashes || {}).filter(([key]) => key.startsWith(`${id}_`)));
|
||||
}
|
||||
|
||||
function replaceHashes(graph, id, records) {
|
||||
if (!Object.keys(records).length && !graph.extra?.anomalous_hashes) return;
|
||||
graph.extra ||= {};
|
||||
graph.extra.anomalous_hashes ||= {};
|
||||
for (const key of Object.keys(graph.extra.anomalous_hashes)) if (key.startsWith(`${id}_`)) delete graph.extra.anomalous_hashes[key];
|
||||
Object.assign(graph.extra.anomalous_hashes, clone(records));
|
||||
}
|
||||
|
||||
export function applyNodeMaterialValues(app, node, entries, options = {}) {
|
||||
const graph = app.graph?.getNodeById(node?.id) === node ? app.graph : (node?.graph || app.canvas?.graph || app.graph);
|
||||
if (!node || graph?.getNodeById(node.id) !== node || !Array.isArray(node.widgets)) throw new Error('materialTargetChanged');
|
||||
const changes = entries.filter(({ index }) => !volatile(node, node.widgets[index], index));
|
||||
if (!changes.length) throw new Error('materialNoCompatibleValues');
|
||||
for (const { index, value } of changes) {
|
||||
const widget = node.widgets[index];
|
||||
if (typeof value === 'number' && !Number.isFinite(value)) throw new Error('materialNoCompatibleValues');
|
||||
if (!Number.isInteger(index) || !widget || value === undefined) throw new Error('materialNoCompatibleValues');
|
||||
const choices = typeof widget.options?.values === 'function' ? widget.options.values() : widget.options?.values;
|
||||
if (Array.isArray(choices) && !choices.includes(value)) throw new Error('materialValueUnavailable');
|
||||
if (widget.value != null && (typeof widget.value !== typeof value || Array.isArray(widget.value) !== Array.isArray(value))) throw new Error('materialNoCompatibleValues');
|
||||
}
|
||||
const previous = node.widgets.map(widget => clone(widget.value));
|
||||
const serialized = clone(node.widgets_values);
|
||||
const hadExtra = Object.hasOwn(graph, 'extra');
|
||||
const hadHashes = !!graph.extra && Object.hasOwn(graph.extra, 'anomalous_hashes');
|
||||
const oldHashes = clone(hashesFor(graph, node.id));
|
||||
const transportsHashes = options.sourceNodeId !== undefined;
|
||||
const mapped = {};
|
||||
if (transportsHashes) {
|
||||
const prefix = `${options.sourceNodeId}_`;
|
||||
for (const [key, value] of Object.entries(options.workflowHashes || {})) {
|
||||
if (key.startsWith(prefix)) mapped[`${node.id}_${key.slice(prefix.length)}`] = clone(value);
|
||||
}
|
||||
}
|
||||
const notify = () => {
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
if (typeof globalThis.CustomEvent === 'function') {
|
||||
globalThis.window?.dispatchEvent(new CustomEvent('graphChanged'));
|
||||
}
|
||||
};
|
||||
const restore = () => {
|
||||
node.widgets.forEach((widget, index) => { widget.value = clone(previous[index]); });
|
||||
if (serialized === undefined) delete node.widgets_values;
|
||||
else node.widgets_values = clone(serialized);
|
||||
if (transportsHashes) {
|
||||
replaceHashes(graph, node.id, oldHashes);
|
||||
if (!hadHashes && graph.extra?.anomalous_hashes && !Object.keys(graph.extra.anomalous_hashes).length) delete graph.extra.anomalous_hashes;
|
||||
if (!hadExtra && graph.extra && !Object.keys(graph.extra).length) delete graph.extra;
|
||||
}
|
||||
};
|
||||
graph.beforeChange?.();
|
||||
try {
|
||||
for (const { index, value } of changes) {
|
||||
const widget = node.widgets[index];
|
||||
widget.value = clone(value);
|
||||
if (Array.isArray(node.widgets_values)) node.widgets_values[index] = clone(value);
|
||||
widget.callback?.call(widget, widget.value, app.canvas, node);
|
||||
node.onWidgetChanged?.(index, widget.value, previous[index], widget);
|
||||
}
|
||||
if (transportsHashes) replaceHashes(graph, node.id, mapped);
|
||||
notify();
|
||||
} catch (error) {
|
||||
restore();
|
||||
notify();
|
||||
throw error;
|
||||
} finally { graph.afterChange?.(); }
|
||||
const appliedSerialized = clone(node.widgets_values);
|
||||
const applied = node.widgets.map(widget => clone(widget.value));
|
||||
const appliedHashes = clone(hashesFor(graph, node.id));
|
||||
let undone = false;
|
||||
return {
|
||||
widgets: changes.length,
|
||||
undo() {
|
||||
if (undone || app.graph !== graph || graph.getNodeById(node.id) !== node
|
||||
|| !same(node.widgets.map(widget => widget.value), applied)
|
||||
|| !same(node.widgets_values, appliedSerialized)
|
||||
|| !same(hashesFor(graph, node.id), appliedHashes)) throw new Error('materialUndoChanged');
|
||||
graph.beforeChange?.();
|
||||
try { restore(); notify(); undone = true; }
|
||||
finally { graph.afterChange?.(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMaterialBlock(app, node, block, workflowHashes) {
|
||||
if (node?.type !== block?.type || !Array.isArray(block.widgets_values)
|
||||
|| block.widgets_values.length > (node.widgets?.length || 0)) throw new Error('materialNoCompatibleValues');
|
||||
return applyNodeMaterialValues(app, node, block.widgets_values.map((value, index) => ({ index, value })),
|
||||
{ sourceNodeId: block.node_id, workflowHashes });
|
||||
}
|
||||
|
||||
export function promptWidgetTargets(node) {
|
||||
const promptNameRegex = /^(text|text_g|text_l|prompt|positive|negative|caption|string|value|文本|提示词|正面|负面|正向|反向|正面提示词|负面提示词|正向提示词|反向提示词|描述|内容)$/i;
|
||||
return (node?.widgets || []).flatMap((widget, index) => {
|
||||
if (!widget) return [];
|
||||
const name = String(widget.name || '');
|
||||
const label = String(widget.label || '');
|
||||
const isNotCombo = !widget.options?.values || !Array.isArray(widget.options.values);
|
||||
const matchesName = promptNameRegex.test(name) || promptNameRegex.test(label) || widget.type === 'customtext' || widget.type === 'text' || !!widget.options?.multiline;
|
||||
return typeof widget.value === 'string' && matchesName && isNotCombo ? [{ index, name: name || label || 'text' }] : [];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/** Create a movable LiteGraph group from the active Prompt Note. */
|
||||
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { translate } from './locales.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
/** Base-model value the backend uses for models whose metadata names no base model. */
|
||||
export const UNLABELED_BASE_MODEL = '__unlabeled__';
|
||||
|
||||
/** UNet / diffusion models carry no text encoder, so their LoRAs patch the model only. */
|
||||
export function isUnetModel(model) {
|
||||
return model?.type === 'unet' || model?.type === 'diffusion_models';
|
||||
}
|
||||
|
||||
export function sendNotebookToCanvas() {
|
||||
if (!this.currentNotebook) return;
|
||||
const data = this.currentNotebook.data || {};
|
||||
if (!data.mainModel) {
|
||||
alert(t('notebookSelectMain'));
|
||||
return;
|
||||
}
|
||||
|
||||
const groupNodes = [];
|
||||
const isUnet = isUnetModel(data.mainModel);
|
||||
|
||||
const ckptNode = LiteGraph.createNode(isUnet ? "UNETLoader" : "CheckpointLoaderSimple");
|
||||
app.graph.add(ckptNode);
|
||||
groupNodes.push({ node: ckptNode, relX: 0, relY: 0 });
|
||||
|
||||
const sub = data.mainModel.subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const relPath = sub ? `${sub}/${data.mainModel.filename}` : data.mainModel.filename;
|
||||
this.setWidgetValuePath(ckptNode, relPath);
|
||||
|
||||
let lastNode = ckptNode;
|
||||
let lastModelSlot = 0;
|
||||
let lastClipSlot = isUnet ? null : 1;
|
||||
|
||||
let relX = 350;
|
||||
let relY = 0;
|
||||
|
||||
data.loras.forEach((lora, idx) => {
|
||||
const loraNode = LiteGraph.createNode(isUnet ? "LoraLoaderModelOnly" : "LoraLoader");
|
||||
app.graph.add(loraNode);
|
||||
groupNodes.push({ node: loraNode, relX: relX, relY: relY });
|
||||
|
||||
const lsub = lora.subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const lrelPath = lsub ? `${lsub}/${lora.filename}` : lora.filename;
|
||||
this.setWidgetValuePath(loraNode, lrelPath);
|
||||
|
||||
lastNode.connect(lastModelSlot, loraNode, 0);
|
||||
if (lastClipSlot !== null) lastNode.connect(lastClipSlot, loraNode, 1);
|
||||
|
||||
lastNode = loraNode;
|
||||
lastModelSlot = 0;
|
||||
if (!isUnet) lastClipSlot = 1;
|
||||
relX += 350;
|
||||
});
|
||||
|
||||
if (data.promptEn) {
|
||||
const posNode = LiteGraph.createNode("CLIPTextEncode");
|
||||
posNode.title = "CLIP Text Encode (Positive)";
|
||||
app.graph.add(posNode);
|
||||
groupNodes.push({ node: posNode, relX: relX, relY: 0 });
|
||||
|
||||
if (posNode.widgets && posNode.widgets.length > 0) {
|
||||
const tw = posNode.widgets.find(w => w.name === 'text' || w.type === 'customtext');
|
||||
if (tw) tw.value = data.promptEn;
|
||||
}
|
||||
if (lastClipSlot !== null) {
|
||||
lastNode.connect(lastClipSlot, posNode, 0);
|
||||
}
|
||||
|
||||
const negNode = LiteGraph.createNode("CLIPTextEncode");
|
||||
negNode.title = "CLIP Text Encode (Negative)";
|
||||
app.graph.add(negNode);
|
||||
groupNodes.push({ node: negNode, relX: relX, relY: 250 });
|
||||
|
||||
if (negNode.widgets && negNode.widgets.length > 0) {
|
||||
const tw = negNode.widgets.find(w => w.name === 'text' || w.type === 'customtext');
|
||||
if (tw) tw.value = "text, watermark, ugly, bad anatomy";
|
||||
}
|
||||
if (lastClipSlot !== null) {
|
||||
lastNode.connect(lastClipSlot, negNode, 0);
|
||||
}
|
||||
}
|
||||
|
||||
this.nbPanel.style.display = 'none';
|
||||
this.close();
|
||||
|
||||
// Magnetic Sticking Logic
|
||||
let isSticking = true;
|
||||
const stickHandler = (e) => {
|
||||
if (!isSticking || !app.canvas) return;
|
||||
const canvas = app.canvas;
|
||||
|
||||
let canvasX, canvasY;
|
||||
if (canvas.convertEventToCanvasOffset) {
|
||||
const pos = canvas.convertEventToCanvasOffset(e);
|
||||
canvasX = pos[0];
|
||||
canvasY = pos[1];
|
||||
} else {
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
canvasX = (e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale;
|
||||
canvasY = (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale;
|
||||
}
|
||||
|
||||
groupNodes.forEach(item => {
|
||||
const w = (item.node.size && item.node.size[0]) ? item.node.size[0] : 200;
|
||||
item.node.pos = [canvasX - (w / 2) + item.relX, canvasY - 20 + item.relY];
|
||||
});
|
||||
canvas.setDirty(true, true);
|
||||
};
|
||||
|
||||
const dropHandler = (e) => {
|
||||
if (!isSticking) return;
|
||||
isSticking = false;
|
||||
window.removeEventListener('mousemove', stickHandler, true);
|
||||
window.removeEventListener('pointerdown', dropHandler, true);
|
||||
window.removeEventListener('mousedown', dropHandler, true);
|
||||
window.removeEventListener('click', dropHandler, true);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', stickHandler, true);
|
||||
setTimeout(() => {
|
||||
window.addEventListener('pointerdown', dropHandler, true);
|
||||
window.addEventListener('mousedown', dropHandler, true);
|
||||
window.addEventListener('click', dropHandler, true);
|
||||
}, 100);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/** Preserve fragment contents and order, including weights and commas. */
|
||||
export function joinPromptText(existing, incoming, position) {
|
||||
if (!['before', 'after'].includes(position)) throw new Error('materialNoCompatibleValues');
|
||||
return (position === 'before' ? [incoming, existing] : [existing, incoming])
|
||||
.filter(value => value && value.trim()).join('\n');
|
||||
}
|
||||
|
||||
export function composePromptPlan(plan) {
|
||||
if (!plan) return { positive: '', negative: '' };
|
||||
const parts = Array.isArray(plan.parts) ? plan.parts : [];
|
||||
const active = parts.filter(part => part && part.enabled !== false);
|
||||
|
||||
return Object.fromEntries(['positive', 'negative'].map(role => {
|
||||
const activeParts = active.filter(p => (p.track || p.role) === role || (!p.track && !p.role && (role === 'positive' ? p.positive : p.negative)));
|
||||
const activeAssembled = assemblePromptBlocks(activeParts, role);
|
||||
const rawFieldText = String(plan[role] || '').trim();
|
||||
|
||||
if (plan.version >= 2) {
|
||||
return [role, rawFieldText || activeAssembled];
|
||||
}
|
||||
|
||||
if (activeParts.length > 0 && rawFieldText) {
|
||||
const activeJoinedNewline = activeParts.map(p => String(p.content ?? p[role] ?? '').trim()).filter(Boolean).join('\n');
|
||||
if (rawFieldText === activeAssembled || rawFieldText === activeJoinedNewline) {
|
||||
return [role, rawFieldText];
|
||||
}
|
||||
}
|
||||
|
||||
const partsText = activeParts.map(part => part[role] || part.content || '').filter(Boolean);
|
||||
return [role, [...partsText, rawFieldText].filter(Boolean).join('\n')];
|
||||
}));
|
||||
}
|
||||
|
||||
const BASE_QUALITY_KEYWORDS = [
|
||||
'masterpiece', 'best quality', 'highly detailed', 'ultra-detailed', '8k', 'hdr',
|
||||
'absurdres', 'highres', 'high resolution', 'perfect anatomy', 'clean background',
|
||||
'worst quality', 'low quality', 'normal quality', 'lowres', 'bad anatomy',
|
||||
'bad hands', 'missing fingers', 'extra digits', 'fewer digits', 'cropped',
|
||||
'jpeg artifacts', 'blurry', 'watermark', 'signature', 'artist name'
|
||||
];
|
||||
|
||||
const TRIGGER_KEYWORDS = [
|
||||
'<lora:', 'trigger:', 'lora:', 'custom dress', 'specific', 'costume', 'outfit'
|
||||
];
|
||||
|
||||
const STYLE_KEYWORDS = [
|
||||
'style', 'lighting', 'cinematic', 'illustration', 'oil painting', 'concept art',
|
||||
'unreal engine', 'octane render', 'vray', 'ray tracing', 'anime', 'photorealistic',
|
||||
'pastel', 'watercolor', 'cyberpunk', 'steampunk', 'glow', 'volumetric'
|
||||
];
|
||||
|
||||
/** Categorize a prompt block based on its keywords and content */
|
||||
export function categorizePromptSnippet(text = '') {
|
||||
const lower = String(text).toLowerCase();
|
||||
if (BASE_QUALITY_KEYWORDS.some(k => lower.includes(k))) return 'base';
|
||||
if (TRIGGER_KEYWORDS.some(k => lower.includes(k))) return 'trigger';
|
||||
if (STYLE_KEYWORDS.some(k => lower.includes(k))) return 'style';
|
||||
return 'subject';
|
||||
}
|
||||
|
||||
/** Smart sort prompt blocks: Same-role blocks first, cross-role blocks at the tail */
|
||||
export function smartSortPromptBlocks(blocks = [], trackRole = 'positive') {
|
||||
const priority = {
|
||||
base: 0,
|
||||
style: 1,
|
||||
subject: 2,
|
||||
trigger: 3,
|
||||
};
|
||||
return [...blocks].sort((a, b) => {
|
||||
const aCross = (a.role && a.role !== trackRole) ? 1 : 0;
|
||||
const bCross = (b.role && b.role !== trackRole) ? 1 : 0;
|
||||
if (aCross !== bCross) {
|
||||
return aCross - bCross;
|
||||
}
|
||||
|
||||
const catA = a.category || categorizePromptSnippet(a.content || a[trackRole] || '');
|
||||
const catB = b.category || categorizePromptSnippet(b.content || b[trackRole] || '');
|
||||
const scoreA = priority[catA] ?? 2;
|
||||
const scoreB = priority[catB] ?? 2;
|
||||
return scoreA - scoreB;
|
||||
});
|
||||
}
|
||||
|
||||
/** Cleanly join prompt blocks with commas and proper spacing */
|
||||
export function assemblePromptBlocks(blocks = [], role = 'positive') {
|
||||
return blocks
|
||||
.filter(b => b && b.enabled !== false)
|
||||
.map(b => {
|
||||
const raw = String(b.content ?? b[role] ?? '').trim();
|
||||
// remove trailing/leading commas from snippet
|
||||
return raw.replace(/^[,,\s]+|[,,\s]+$/g, '');
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(',\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Losslessly converts any prompt plan (legacy v1 with dual-role blocks or loose text, or v2)
|
||||
* into the standardized workbench draft representation with role isolation.
|
||||
*/
|
||||
export function planToWorkbenchDraft(planData = {}, defaultName = '') {
|
||||
const rawParts = Array.isArray(planData.parts) ? planData.parts : [];
|
||||
const convertedBlocks = [];
|
||||
const makeId = (prefix, idx) => `${prefix}_${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
rawParts.forEach((part, idx) => {
|
||||
if (!part) return;
|
||||
const name = String(part.name || part.title || '').trim();
|
||||
const enabled = part.enabled !== false;
|
||||
const category = part.category || 'general';
|
||||
|
||||
const hasPos = typeof part.positive === 'string' && part.positive.trim().length > 0;
|
||||
const hasNeg = typeof part.negative === 'string' && part.negative.trim().length > 0;
|
||||
|
||||
if (hasPos && hasNeg) {
|
||||
convertedBlocks.push({
|
||||
id: part.id ? `${part.id}_pos` : makeId('blk_pos', idx),
|
||||
title: name ? `${name} (Pos)` : 'Positive',
|
||||
content: part.positive.trim(),
|
||||
role: 'positive',
|
||||
category: category === 'general' ? categorizePromptSnippet(part.positive) : category,
|
||||
enabled,
|
||||
});
|
||||
convertedBlocks.push({
|
||||
id: part.id ? `${part.id}_neg` : makeId('blk_neg', idx),
|
||||
title: name ? `${name} (Neg)` : 'Negative',
|
||||
content: part.negative.trim(),
|
||||
role: 'negative',
|
||||
category: category === 'general' ? 'base' : category,
|
||||
enabled,
|
||||
});
|
||||
} else if (hasNeg || part.role === 'negative') {
|
||||
const content = String(part.content ?? part.negative ?? '').trim();
|
||||
if (content) {
|
||||
convertedBlocks.push({
|
||||
id: part.id || makeId('blk', idx),
|
||||
title: name || 'Negative',
|
||||
content,
|
||||
role: 'negative',
|
||||
category: category === 'general' ? 'base' : category,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const content = String(part.content ?? part.positive ?? '').trim();
|
||||
if (content) {
|
||||
convertedBlocks.push({
|
||||
id: part.id || makeId('blk', idx),
|
||||
title: name || 'Positive',
|
||||
content,
|
||||
role: 'positive',
|
||||
category: category === 'general' ? categorizePromptSnippet(content) : category,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Check for legacy trailing unassigned text (for legacy v1 plans)
|
||||
if (!planData.version || planData.version < 2) {
|
||||
['positive', 'negative'].forEach(role => {
|
||||
const rawText = String(planData[role] || '').trim();
|
||||
if (!rawText) return;
|
||||
|
||||
const existingRoleBlocks = convertedBlocks.filter(b => b.role === role);
|
||||
const assembledRoleText = assemblePromptBlocks(existingRoleBlocks, role);
|
||||
const joinedRoleText = existingRoleBlocks.map(b => b.content).join('\n');
|
||||
|
||||
if (rawText !== assembledRoleText && rawText !== joinedRoleText) {
|
||||
if (existingRoleBlocks.length === 0) {
|
||||
convertedBlocks.push({
|
||||
id: makeId(`blk_${role}_init`, convertedBlocks.length),
|
||||
title: role === 'positive' ? '正向提示词' : '负向提示词',
|
||||
content: rawText,
|
||||
role,
|
||||
category: role === 'positive' ? categorizePromptSnippet(rawText) : 'base',
|
||||
enabled: true,
|
||||
});
|
||||
} else {
|
||||
let trailing = rawText;
|
||||
if (trailing.startsWith(assembledRoleText)) {
|
||||
trailing = trailing.slice(assembledRoleText.length).replace(/^[,,\n\s]+/, '').trim();
|
||||
} else if (trailing.startsWith(joinedRoleText)) {
|
||||
trailing = trailing.slice(joinedRoleText.length).replace(/^[,,\n\s]+/, '').trim();
|
||||
}
|
||||
if (trailing) {
|
||||
convertedBlocks.push({
|
||||
id: makeId(`blk_${role}_tail`, convertedBlocks.length),
|
||||
title: role === 'positive' ? '附加正向词' : '附加负向词',
|
||||
content: trailing,
|
||||
role,
|
||||
category: role === 'positive' ? categorizePromptSnippet(trailing) : 'base',
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const draft = {
|
||||
name: String(planData.name || defaultName || '').trim(),
|
||||
tags: Array.isArray(planData.tags) ? planData.tags : [],
|
||||
plan: {
|
||||
version: 2,
|
||||
parts: convertedBlocks,
|
||||
positive: assemblePromptBlocks(convertedBlocks.filter(p => p.role === 'positive'), 'positive'),
|
||||
negative: assemblePromptBlocks(convertedBlocks.filter(p => p.role === 'negative'), 'negative'),
|
||||
},
|
||||
};
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a workbench draft into the standard backend-storable prompt plan object.
|
||||
*/
|
||||
export function workbenchDraftToSavedPlan(draft) {
|
||||
const parts = Array.isArray(draft?.plan?.parts) ? draft.plan.parts : [];
|
||||
const posParts = parts.filter(p => p.role === 'positive');
|
||||
const negParts = parts.filter(p => p.role === 'negative');
|
||||
|
||||
return {
|
||||
name: String(draft.name || '').trim(),
|
||||
tags: Array.isArray(draft.tags) ? draft.tags : [],
|
||||
plan: {
|
||||
version: 2,
|
||||
parts: parts.map(p => ({
|
||||
name: String(p.title || p.name || '').trim().slice(0, 120),
|
||||
positive: p.role === 'positive' ? String(p.content || '').trim() : '',
|
||||
negative: p.role === 'negative' ? String(p.content || '').trim() : '',
|
||||
category: p.category || (p.role === 'negative' ? 'base' : categorizePromptSnippet(p.content)),
|
||||
enabled: p.enabled !== false,
|
||||
})),
|
||||
positive: assemblePromptBlocks(posParts, 'positive'),
|
||||
negative: assemblePromptBlocks(negParts, 'negative'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { loadMaterialPrompts } from './material_prompt_data.js';
|
||||
import { categorizePromptSnippet } from './prompt_composition.js';
|
||||
import { jsonResponse } from './ui_dom.js';
|
||||
|
||||
// A complete snapshot is required before replacing library-owned cards.
|
||||
export async function loadPromptSourceCards(signal) {
|
||||
const cards = [];
|
||||
const filenames = new Set();
|
||||
let page = 1;
|
||||
let pages = 1;
|
||||
do {
|
||||
signal.throwIfAborted();
|
||||
const response = await fetch(`/anomalous/materials?category=prompts&limit=100&page=${page}`, { signal });
|
||||
const payload = await jsonResponse(response, 'materialLoadError');
|
||||
if (payload.status !== 'success' || !Array.isArray(payload.materials)) throw new Error('materialLoadError');
|
||||
pages = Number(payload.pages) || 1;
|
||||
for (const item of payload.materials) {
|
||||
signal.throwIfAborted();
|
||||
if (filenames.has(item.filename)) continue;
|
||||
filenames.add(item.filename);
|
||||
const prompts = await loadMaterialPrompts(item.filename, signal);
|
||||
for (const role of ['positive', 'negative']) {
|
||||
const content = prompts[role]?.trim();
|
||||
if (!content) continue;
|
||||
cards.push({
|
||||
id: `mat_${role}_${item.filename}`, filename: item.filename,
|
||||
title: item.name || item.filename, content, role,
|
||||
category: role === 'negative' ? 'base' : categorizePromptSnippet(content),
|
||||
persisted: true, sourceKind: 'material',
|
||||
});
|
||||
}
|
||||
}
|
||||
page++;
|
||||
} while (page <= pages);
|
||||
signal.throwIfAborted();
|
||||
return cards;
|
||||
}
|
||||
|
||||
export function mergePromptSourceCards(existing, incoming) {
|
||||
const oldIds = new Set(existing.filter(card => card.sourceKind === 'material').map(card => card.id));
|
||||
const local = existing.filter(card => card.sourceKind !== 'material');
|
||||
const unique = [...new Map(incoming.map(card => [card.id, card])).values()];
|
||||
const added = unique.filter(card => !oldIds.has(card.id)).length;
|
||||
existing.splice(0, existing.length, ...local, ...unique);
|
||||
return added;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { categorizePromptSnippet, assemblePromptBlocks } from './prompt_composition.js';
|
||||
export const newDraft = () => ({
|
||||
name: '',
|
||||
tags: [],
|
||||
plan: {
|
||||
version: 2,
|
||||
parts: [],
|
||||
positive: '',
|
||||
negative: '',
|
||||
},
|
||||
});
|
||||
|
||||
export const CATEGORY_META = {
|
||||
base: { zh: '通用底模', en: 'Base Quality', color: '#38bdf8', bg: 'rgba(56, 189, 248, 0.15)', border: 'rgba(56, 189, 248, 0.4)' },
|
||||
style: { zh: '风格氛围', en: 'Art Style', color: '#c084fc', bg: 'rgba(192, 132, 252, 0.15)', border: 'rgba(192, 132, 252, 0.4)' },
|
||||
subject: { zh: '主体内容', en: 'Subject', color: '#4ade80', bg: 'rgba(74, 222, 128, 0.15)', border: 'rgba(74, 222, 128, 0.4)' },
|
||||
trigger: { zh: 'LoRA/触发', en: 'LoRA / Trigger', color: '#fb923c', bg: 'rgba(251, 146, 60, 0.15)', border: 'rgba(251, 146, 60, 0.4)' },
|
||||
};
|
||||
|
||||
// Built-in starter prompt cards for left source deck
|
||||
export const STARTER_SOURCE_PROMPTS = [
|
||||
{
|
||||
id: 'preset_base_quality',
|
||||
title: '画质底模词 (Quality Base)',
|
||||
content: 'masterpiece, best quality, highly detailed, ultra-detailed, 8k, hdr, absurdres',
|
||||
role: 'positive',
|
||||
category: 'base',
|
||||
},
|
||||
{
|
||||
id: 'preset_real_detail',
|
||||
title: '写实与材质增强 (Realistic Detail)',
|
||||
content: 'highres, realistic skin texture, sharp focus, subsurface scattering, 35mm photograph',
|
||||
role: 'positive',
|
||||
category: 'base',
|
||||
},
|
||||
{
|
||||
id: 'preset_cinematic_light',
|
||||
title: '电影胶片光影 (Cinematic Lighting)',
|
||||
content: 'cinematic lighting, dramatic shadows, soft volumetric glow, ray tracing, atmospheric',
|
||||
role: 'positive',
|
||||
category: 'style',
|
||||
},
|
||||
{
|
||||
id: 'preset_anime_cyber',
|
||||
title: '赛博霓虹风 (Cyberpunk Neon)',
|
||||
content: 'anime style, cyberpunk aesthetic, vibrant neon reflections, futuristic metropolis backdrop',
|
||||
role: 'positive',
|
||||
category: 'style',
|
||||
},
|
||||
{
|
||||
id: 'preset_girl_face',
|
||||
title: '美少女面部特写 (Portrait 1girl)',
|
||||
content: '1girl, beautiful detailed expressive eyes, smile, delicate face, soft wind-blown hair',
|
||||
role: 'positive',
|
||||
category: 'subject',
|
||||
},
|
||||
{
|
||||
id: 'preset_neg_filter',
|
||||
title: '通用负向过滤词 (Negative Base Filter)',
|
||||
content: 'worst quality, low quality, normal quality, lowres, bad anatomy, bad hands, missing fingers, extra digits, cropped, blurry, watermark',
|
||||
role: 'negative',
|
||||
category: 'base',
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeBlock(part, index = 0) {
|
||||
const id = part.id || `blk_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const role = part.role || (part.negative && !part.positive ? 'negative' : 'positive');
|
||||
const track = part.track || role;
|
||||
const content = String(part.content ?? (role === 'positive' ? part.positive : part.negative) ?? '').trim();
|
||||
let category = part.category;
|
||||
if (!CATEGORY_META[category]) {
|
||||
category = categorizePromptSnippet(content);
|
||||
}
|
||||
const meta = CATEGORY_META[category] || CATEGORY_META.subject;
|
||||
const defaultTitle = (window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en) + (index ? ` #${index + 1}` : '');
|
||||
return {
|
||||
id,
|
||||
title: part.name || part.title || defaultTitle,
|
||||
content,
|
||||
role,
|
||||
track,
|
||||
category,
|
||||
enabled: part.enabled !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export function syncDraftSynthesizedText(draft) {
|
||||
if (!draft || !draft.plan) return;
|
||||
draft.plan.parts ||= [];
|
||||
const posParts = draft.plan.parts.filter(p => (p.track || p.role) === 'positive');
|
||||
const negParts = draft.plan.parts.filter(p => (p.track || p.role) === 'negative');
|
||||
draft.plan.positive = assemblePromptBlocks(posParts, 'positive');
|
||||
draft.plan.negative = assemblePromptBlocks(negParts, 'negative');
|
||||
}
|
||||
|
||||
// Full-text edits replace the enabled blocks of one role. Keep disabled blocks
|
||||
// and the other role intact; synthesized fields are always derived from parts.
|
||||
export function replaceDraftRoleText(draft, role, content) {
|
||||
const parts = draft.plan.parts || [];
|
||||
const first = parts.findIndex(part => (part.track || part.role) === role && part.enabled !== false);
|
||||
const replacement = content.trim() ? normalizeBlock({ content, role, track: role }) : null;
|
||||
const next = [];
|
||||
parts.forEach((part, index) => {
|
||||
if (index === first && replacement) next.push(replacement);
|
||||
if ((part.track || part.role) !== role || part.enabled === false) next.push(part);
|
||||
});
|
||||
if (first < 0 && replacement) next.push(replacement);
|
||||
draft.plan.parts = next;
|
||||
syncDraftSynthesizedText(draft);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { mergeRecipeHashRecords } from './recipe_provenance.js';
|
||||
|
||||
function cloneJson(value) {
|
||||
if (value === undefined) return undefined;
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function nodeType(node) {
|
||||
return String(node?.type || node?.class_type || '').trim();
|
||||
}
|
||||
|
||||
function savedNodeRecords(workflow) {
|
||||
return Array.isArray(workflow?.nodes) ? workflow.nodes.filter((node) => node && node.id !== undefined) : [];
|
||||
}
|
||||
|
||||
function nodeShape(node) {
|
||||
const inputs = Array.isArray(node?.inputs) ? node.inputs.length : -1;
|
||||
const outputs = Array.isArray(node?.outputs) ? node.outputs.length : -1;
|
||||
return `${nodeType(node)}|${inputs}|${outputs}`;
|
||||
}
|
||||
|
||||
function nodeTitle(node) {
|
||||
return String(node?.title || node?.properties?.['Node name for S&R'] || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function liveNodeById(graph, id) {
|
||||
return graph?.getNodeById?.(id)
|
||||
|| graph?.getNodeById?.(Number(id))
|
||||
|| graph?._nodes?.find((node) => String(node?.id) === String(id))
|
||||
|| null;
|
||||
}
|
||||
|
||||
function volatileWidget(node, widget, index) {
|
||||
const name = String(widget?.name || '').toLowerCase();
|
||||
if (/(^|[_\s-])(seed|noise_seed|random_seed|variation_seed|last_seed)([_\s-]|$)/i.test(name)) return true;
|
||||
const type = nodeType(node).toLowerCase();
|
||||
if (type === 'ksampler') return index === 0;
|
||||
if (type === 'ksampleradvanced') return index === 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchSkeleton(sourceWorkflow, candidateWorkflow) {
|
||||
const sourceNodes = savedNodeRecords(sourceWorkflow);
|
||||
const candidateNodes = savedNodeRecords(candidateWorkflow);
|
||||
const available = new Set(candidateNodes);
|
||||
const matches = new Map();
|
||||
const missing = [];
|
||||
for (const source of sourceNodes) {
|
||||
const exact = [...available].find((candidate) => (
|
||||
String(candidate.id) === String(source.id)
|
||||
&& nodeType(candidate) === nodeType(source)
|
||||
&& nodeShape(candidate) === nodeShape(source)
|
||||
));
|
||||
const sameTitle = [...available].find((candidate) => (
|
||||
nodeShape(candidate) === nodeShape(source)
|
||||
&& nodeTitle(candidate) === nodeTitle(source)
|
||||
&& nodeTitle(source)
|
||||
));
|
||||
const sameShape = [...available].find((candidate) => nodeShape(candidate) === nodeShape(source));
|
||||
const sameType = [...available].find((candidate) => nodeType(candidate) === nodeType(source));
|
||||
const target = exact || sameTitle || sameShape || sameType;
|
||||
if (!target) {
|
||||
missing.push(nodeType(source) || String(source.id));
|
||||
continue;
|
||||
}
|
||||
available.delete(target);
|
||||
matches.set(String(source.id), target);
|
||||
}
|
||||
return { matches, missing, sourceCount: sourceNodes.length, candidateCount: candidateNodes.length };
|
||||
}
|
||||
|
||||
export function assertRecipeSkeleton(sourceWorkflow, candidateWorkflow) {
|
||||
const result = matchSkeleton(sourceWorkflow, candidateWorkflow);
|
||||
if (result.missing.length) {
|
||||
const error = new Error(`Missing recipe skeleton nodes: ${result.missing.join(', ')}`);
|
||||
error.code = 'recipe_parameter_skeleton_mismatch';
|
||||
error.missing = result.missing;
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Apply only saved widget values to a matching local graph, with rollback. */
|
||||
export function applyRecipeParametersToCanvas(source) {
|
||||
const graph = app.graph;
|
||||
const sourceWorkflow = source?.workflow;
|
||||
const candidateWorkflow = graph?.serialize?.();
|
||||
if (!graph || !sourceWorkflow || !candidateWorkflow) throw new Error('recipe_parameter_apply_unavailable');
|
||||
const skeleton = assertRecipeSkeleton(sourceWorkflow, candidateWorkflow);
|
||||
const sourceNodes = savedNodeRecords(sourceWorkflow);
|
||||
const changes = [];
|
||||
for (const sourceNode of sourceNodes) {
|
||||
const targetRecord = skeleton.matches.get(String(sourceNode.id));
|
||||
if (!targetRecord || !Array.isArray(sourceNode.widgets_values)) continue;
|
||||
// `candidateWorkflow` is a serialized graph and intentionally has no
|
||||
// live widget objects. Resolve the matched record back to ComfyUI's
|
||||
// runtime node before reading callbacks or writing widget values.
|
||||
const target = liveNodeById(graph, targetRecord.id);
|
||||
if (!target) {
|
||||
const error = new Error(`Could not resolve live node ${targetRecord.id} on ${nodeType(targetRecord)}`);
|
||||
error.code = 'recipe_parameter_node_unavailable';
|
||||
throw error;
|
||||
}
|
||||
const targetWidgets = Array.isArray(target.widgets) ? target.widgets : [];
|
||||
for (let index = 0; index < sourceNode.widgets_values.length; index += 1) {
|
||||
if (volatileWidget(sourceNode, targetWidgets[index], index)) continue;
|
||||
if (!targetWidgets[index]) {
|
||||
const error = new Error(`Missing widget ${index} on ${nodeType(target)}`);
|
||||
error.code = 'recipe_parameter_widget_mismatch';
|
||||
throw error;
|
||||
}
|
||||
changes.push({
|
||||
sourceNode,
|
||||
target,
|
||||
targetWidget: targetWidgets[index],
|
||||
index,
|
||||
value: cloneJson(sourceNode.widgets_values[index]),
|
||||
previousValue: cloneJson(targetWidgets[index].value),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const previous = changes.map((change) => ({ ...change, previousValue: cloneJson(change.previousValue) }));
|
||||
graph.beforeChange?.();
|
||||
try {
|
||||
for (const change of changes) {
|
||||
change.targetWidget.value = cloneJson(change.value);
|
||||
if (Array.isArray(change.target.widgets_values)) change.target.widgets_values[change.index] = cloneJson(change.value);
|
||||
if (typeof change.targetWidget.callback === 'function') {
|
||||
change.targetWidget.callback.call(change.targetWidget, change.targetWidget.value, app.canvas, change.target);
|
||||
}
|
||||
if (typeof change.target.onWidgetChanged === 'function') {
|
||||
// Current ComfyUI uses the fourth argument as the live widget
|
||||
// for its error-clearing hooks. Omitting it makes those hooks
|
||||
// evaluate `sourceNodeId in undefined`.
|
||||
change.target.onWidgetChanged(
|
||||
change.index,
|
||||
change.targetWidget.value,
|
||||
change.previousValue,
|
||||
change.targetWidget,
|
||||
);
|
||||
}
|
||||
}
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
return { nodes: sourceNodes.length, widgets: changes.length };
|
||||
} catch (error) {
|
||||
for (const change of previous) {
|
||||
change.targetWidget.value = cloneJson(change.previousValue);
|
||||
if (Array.isArray(change.target.widgets_values)) change.target.widgets_values[change.index] = cloneJson(change.previousValue);
|
||||
}
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
throw error;
|
||||
} finally {
|
||||
graph.afterChange?.();
|
||||
}
|
||||
}
|
||||
|
||||
function linkRecords(workflow) {
|
||||
if (Array.isArray(workflow?.links)) return workflow.links.filter((link) => Array.isArray(link) || link && typeof link === 'object');
|
||||
if (workflow?.links instanceof Map) return [...workflow.links.values()].filter(Boolean);
|
||||
if (workflow?.links && typeof workflow.links === 'object') return Object.values(workflow.links).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
function savedGroupRecords(workflow) {
|
||||
return Array.isArray(workflow?.groups) ? workflow.groups.filter((group) => group && typeof group === 'object') : [];
|
||||
}
|
||||
|
||||
function graphNode(graph, id) {
|
||||
return graph?.getNodeById?.(id)
|
||||
|| graph?._nodes?.find((node) => String(node?.id) === String(id))
|
||||
|| null;
|
||||
}
|
||||
|
||||
function nextNodeId(graph, usedIds) {
|
||||
let max = 0;
|
||||
for (const node of graph?._nodes || []) max = Math.max(max, Number(node?.id) || 0);
|
||||
for (const id of usedIds) max = Math.max(max, Number(id) || 0);
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
function liveBounds(graph) {
|
||||
const nodes = (graph?._nodes || []).filter(Boolean);
|
||||
if (!nodes.length) return null;
|
||||
return nodes.reduce((bounds, node) => {
|
||||
const x = Number(node.pos?.[0]) || 0;
|
||||
const y = Number(node.pos?.[1]) || 0;
|
||||
const width = Number(node.size?.[0]) || 220;
|
||||
const height = Number(node.size?.[1]) || 120;
|
||||
bounds.minX = Math.min(bounds.minX, x);
|
||||
bounds.minY = Math.min(bounds.minY, y);
|
||||
bounds.maxX = Math.max(bounds.maxX, x + width);
|
||||
bounds.maxY = Math.max(bounds.maxY, y + height);
|
||||
return bounds;
|
||||
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
|
||||
}
|
||||
|
||||
function savedBounds(nodes) {
|
||||
if (!nodes.length) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||||
return nodes.reduce((bounds, node) => {
|
||||
const x = Number(node.pos?.[0]) || 0;
|
||||
const y = Number(node.pos?.[1]) || 0;
|
||||
const width = Number(node.size?.[0]) || 220;
|
||||
const height = Number(node.size?.[1]) || 120;
|
||||
bounds.minX = Math.min(bounds.minX, x);
|
||||
bounds.minY = Math.min(bounds.minY, y);
|
||||
bounds.maxX = Math.max(bounds.maxX, x + width);
|
||||
bounds.maxY = Math.max(bounds.maxY, y + height);
|
||||
return bounds;
|
||||
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
|
||||
}
|
||||
|
||||
function instantiateNode(nodeData) {
|
||||
const type = nodeType(nodeData);
|
||||
const creator = globalThis.LiteGraph?.createNode;
|
||||
if (typeof creator !== 'function' || !type) return null;
|
||||
const node = creator.call(globalThis.LiteGraph, type);
|
||||
if (!node) return null;
|
||||
node.configure?.(cloneJson(nodeData));
|
||||
return node;
|
||||
}
|
||||
|
||||
function instantiateGroup(groupData) {
|
||||
const GroupClass = globalThis.LiteGraph?.LGraphGroup;
|
||||
if (typeof GroupClass !== 'function') return null;
|
||||
const group = new GroupClass(String(groupData?.title || ''));
|
||||
group.configure?.(cloneJson(groupData));
|
||||
return group;
|
||||
}
|
||||
|
||||
function linkField(link, arrayIndex, objectKey) {
|
||||
return Array.isArray(link) ? link[arrayIndex] : link?.[objectKey];
|
||||
}
|
||||
|
||||
function connectLink(link, idMap, graph) {
|
||||
const originId = linkField(link, 1, 'origin_id');
|
||||
const targetId = linkField(link, 3, 'target_id');
|
||||
const originSlot = linkField(link, 2, 'origin_slot');
|
||||
const targetSlot = linkField(link, 4, 'target_slot');
|
||||
if (originId === undefined || targetId === undefined || originSlot === undefined || targetSlot === undefined) return false;
|
||||
const origin = graphNode(graph, idMap.get(String(originId)));
|
||||
const target = graphNode(graph, idMap.get(String(targetId)));
|
||||
if (!origin || !target) return false;
|
||||
const result = origin.connect?.(Number(originSlot), target, Number(targetSlot));
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
/** Merge one serialized workflow into the live graph as one rollback-able action. */
|
||||
export function appendRecipeToCanvas(recipe) {
|
||||
const graph = app.graph;
|
||||
const savedNodes = savedNodeRecords(recipe?.workflow);
|
||||
const savedLinks = linkRecords(recipe?.workflow);
|
||||
const savedGroups = savedGroupRecords(recipe?.workflow);
|
||||
if (!graph || !savedNodes.length) throw new Error('recipe_append_empty');
|
||||
if (typeof globalThis.LiteGraph?.createNode !== 'function') throw new Error('recipe_append_unsupported');
|
||||
if (recipe?.workflow?.definitions?.subgraphs?.length) {
|
||||
throw new Error('recipe_append_subgraphs_unsupported');
|
||||
}
|
||||
if (savedGroups.length && typeof globalThis.LiteGraph?.LGraphGroup !== 'function') {
|
||||
throw new Error('recipe_append_groups_unsupported');
|
||||
}
|
||||
|
||||
const idMap = new Map();
|
||||
const inserted = [];
|
||||
const existingIds = new Set((graph._nodes || []).map((node) => String(node?.id)));
|
||||
let id = nextNodeId(graph, savedNodes.map((node) => node.id));
|
||||
const sourceBounds = savedBounds(savedNodes);
|
||||
const targetBounds = liveBounds(graph);
|
||||
const offsetX = targetBounds ? targetBounds.maxX + 140 - sourceBounds.minX : 40 - sourceBounds.minX;
|
||||
const offsetY = targetBounds ? targetBounds.minY - sourceBounds.minY : 40 - sourceBounds.minY;
|
||||
const insertedGroups = [];
|
||||
const hadHashRecords = Boolean(graph.extra && Object.prototype.hasOwnProperty.call(graph.extra, 'anomalous_hashes'));
|
||||
const previousHashRecords = cloneJson(graph.extra?.anomalous_hashes);
|
||||
|
||||
graph.beforeChange?.();
|
||||
try {
|
||||
for (const nodeData of savedNodes) {
|
||||
const node = instantiateNode(nodeData);
|
||||
if (!node) {
|
||||
const error = new Error(`Missing node type: ${nodeType(nodeData)}`);
|
||||
error.code = 'recipe_append_missing_node';
|
||||
throw error;
|
||||
}
|
||||
while (existingIds.has(String(id))) id += 1;
|
||||
const oldId = String(nodeData.id);
|
||||
idMap.set(oldId, id);
|
||||
node.id = id;
|
||||
existingIds.add(String(id));
|
||||
const x = Number(nodeData.pos?.[0]) || 0;
|
||||
const y = Number(nodeData.pos?.[1]) || 0;
|
||||
node.pos = [x + offsetX, y + offsetY];
|
||||
graph.add(node);
|
||||
inserted.push(node);
|
||||
id += 1;
|
||||
}
|
||||
|
||||
for (const link of savedLinks) {
|
||||
if (!connectLink(link, idMap, graph)) {
|
||||
const error = new Error('Invalid link in recipe');
|
||||
error.code = 'recipe_append_invalid_link';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
for (const groupData of savedGroups) {
|
||||
const group = instantiateGroup(groupData);
|
||||
if (!group) {
|
||||
const error = new Error('Could not restore a recipe group.');
|
||||
error.code = 'recipe_append_groups_unsupported';
|
||||
throw error;
|
||||
}
|
||||
if (Array.isArray(group.bounding) && group.bounding.length >= 4) {
|
||||
group.bounding = [
|
||||
Number(group.bounding[0]) + offsetX,
|
||||
Number(group.bounding[1]) + offsetY,
|
||||
group.bounding[2],
|
||||
group.bounding[3],
|
||||
];
|
||||
}
|
||||
graph.add(group);
|
||||
insertedGroups.push(group);
|
||||
}
|
||||
const hashRecords = mergeRecipeHashRecords(graph, recipe.workflow, idMap);
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
app.canvas?.selectNodes?.(inserted);
|
||||
app.canvas?.selectItems?.(inserted);
|
||||
return { nodes: inserted.length, links: savedLinks.length, groups: insertedGroups.length, hashRecords };
|
||||
} catch (error) {
|
||||
for (const group of insertedGroups) graph.remove?.(group);
|
||||
for (const node of inserted) graph.remove?.(node);
|
||||
if (hadHashRecords) {
|
||||
if (!graph.extra || typeof graph.extra !== 'object') graph.extra = {};
|
||||
graph.extra.anomalous_hashes = previousHashRecords;
|
||||
} else if (graph.extra && typeof graph.extra === 'object') {
|
||||
delete graph.extra.anomalous_hashes;
|
||||
}
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
throw error;
|
||||
} finally {
|
||||
graph.afterChange?.();
|
||||
}
|
||||
}
|
||||