Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcad1bf133 | ||
|
|
942733fc22 | ||
|
|
0b212cc1b4 | ||
|
|
f902f60b4d | ||
|
|
fe27ee3ba7 | ||
|
|
4e21b21a72 | ||
|
|
e0129eaa2f | ||
|
|
a23c3ec69d | ||
|
|
d2b6c40921 | ||
|
|
594cb70dee | ||
|
|
3f5774a724 | ||
|
|
aa9c237f98 | ||
|
|
9481729c14 | ||
|
|
f0dca38ffb | ||
|
|
9c7fe63f60 | ||
|
|
32c7fbd95a | ||
|
|
d1c820d931 | ||
|
|
354b8e16a4 | ||
|
|
26a39f781d | ||
|
|
9d4fe22030 | ||
|
|
314dc1cea4 | ||
|
|
59b46f07f9 | ||
|
|
1e1555ff27 | ||
|
|
2eb35f113c | ||
|
|
e4d351a44e | ||
|
|
888e7c8dc4 | ||
|
|
fe8e08d8cf | ||
|
|
d8e12b8a59 | ||
|
|
afe73258d5 | ||
|
|
0c528cc281 | ||
|
|
517e7efaf2 | ||
|
|
6c8d3e8eb4 | ||
|
|
01dcdf37a9 | ||
|
|
7f96afe459 | ||
|
|
800e51a6b4 | ||
|
|
c7a3ce5b1b | ||
|
|
8e05c82cbf | ||
|
|
7245037315 | ||
|
|
41c62d8667 | ||
|
|
d00882925c | ||
|
|
e6de76502a | ||
|
|
3a55eb08a9 | ||
|
|
1d02d5b74d | ||
|
|
fc4e1a7fd1 | ||
|
|
302fe7e6e8 | ||
|
|
074e9e43a0 | ||
|
|
0f7d6317ad | ||
|
|
7bea74dd09 | ||
|
|
dbf2022b06 | ||
|
|
a93ed042d2 | ||
|
|
3593e50e9f | ||
|
|
0b13f3b431 | ||
|
|
012c5ea8b3 | ||
|
|
e04fa66b0d | ||
|
|
4cfff5382e | ||
|
|
2104bb659f | ||
|
|
81787cf139 | ||
|
|
915feff820 | ||
|
|
c86d48faa2 | ||
|
|
5b82452514 | ||
|
|
00db5c6fc5 | ||
|
|
a8b75d33a6 | ||
|
|
87aa9a1ac2 | ||
|
|
65ffa602e2 | ||
|
|
cab02179e8 | ||
|
|
c863dacf36 | ||
|
|
718cc0be70 | ||
|
|
20424e4428 | ||
|
|
4dd183ead4 | ||
|
|
d379581853 | ||
|
|
054cb91b9b | ||
|
|
51b7113f5c | ||
|
|
a2a11ca2cf | ||
|
|
4146872765 | ||
|
|
5492d4a5e1 | ||
|
|
264b098b34 | ||
|
|
307381c439 | ||
|
|
96736560af | ||
|
|
a173e4e561 | ||
|
|
86d165b891 | ||
|
|
4abb0100a7 | ||
|
|
715b8b7271 | ||
|
|
2358649f03 | ||
|
|
e466e9dfcf | ||
|
|
e689cc3dac | ||
|
|
e647ad03b1 | ||
|
|
00ea3f78c4 | ||
|
|
a2e3077726 | ||
|
|
fb21e6c6e9 | ||
|
|
5af9b6d260 | ||
|
|
f13b725d97 | ||
|
|
8c1ad49e25 | ||
|
|
3a221b71d3 | ||
|
|
230566b864 | ||
|
|
9b0b95ff22 | ||
|
|
376284e647 | ||
|
|
4ade73550c | ||
|
|
46facb9115 | ||
|
|
7da4e1d91e | ||
|
|
805b297a80 | ||
|
|
6331b25241 | ||
|
|
c8689d56e4 | ||
|
|
32a3719d97 | ||
|
|
a083cfdc54 | ||
|
|
21d8ae5800 | ||
|
|
721a006b16 | ||
|
|
6cb9ea39be | ||
|
|
e341b8a26d | ||
|
|
ff726db2f6 | ||
|
|
a4a49d9d0c | ||
|
|
3ba16e874c | ||
|
|
42d8c7bbfc | ||
|
|
7c97b73779 | ||
|
|
c19e3bb711 | ||
|
|
685de2fa15 | ||
|
|
5fd9e8c872 | ||
|
|
713e06550c | ||
|
|
660f1839b0 | ||
|
|
a4cbe72086 | ||
|
|
4e8603b4cc | ||
|
|
e1ac84f6d8 | ||
|
|
120f0f7665 | ||
|
|
a7749beed4 | ||
|
|
af3bb60981 | ||
|
|
4fe746af2c | ||
|
|
d4603f8e8a | ||
|
|
4396ca375c | ||
|
|
9fc38dc69f | ||
|
|
bd7fb8beaa | ||
|
|
55208061a8 | ||
|
|
3274636720 | ||
|
|
f220c63315 | ||
|
|
56882fd1e4 | ||
|
|
a8d02f135b | ||
|
|
248e137f23 | ||
|
|
144c32929e | ||
|
|
5dc570d880 | ||
|
|
c0a6beff04 | ||
|
|
fd966a7ee3 | ||
|
|
ed86a92aeb | ||
|
|
ad8596ad48 | ||
|
|
ccb2beb490 | ||
|
|
9e761d0705 | ||
|
|
cb26b763b8 | ||
|
|
01fc925bd6 | ||
|
|
634b2384cf | ||
|
|
9f45fef85b | ||
|
|
2737c1b5b0 | ||
|
|
98992063be | ||
|
|
f7873ce106 | ||
|
|
e788306291 | ||
|
|
ca32fe09ed | ||
|
|
eabff31c1c | ||
|
|
41e2e8805b | ||
|
|
c79b88d464 | ||
|
|
7b79e2b7d9 | ||
|
|
61b828c716 | ||
|
|
2a874be1bd | ||
|
|
58e49cc635 | ||
|
|
efae0b84df | ||
|
|
585a745683 | ||
|
|
49514025c2 | ||
|
|
0fd50b1a18 | ||
|
|
e622850349 | ||
|
|
39e99db53d | ||
|
|
3768c8ec44 | ||
|
|
dbb645feb5 | ||
|
|
0922b27b56 | ||
|
|
c5eaca1dc4 | ||
|
|
e0776891c8 | ||
|
|
1476fafd8a | ||
|
|
83bce45af6 | ||
|
|
b319fa1027 | ||
|
|
f04c5d8f8c | ||
|
|
a8e81beeb0 | ||
|
|
d69507724e | ||
|
|
fa0482393f | ||
|
|
40195aa842 | ||
|
|
66979f698a | ||
|
|
9038637967 | ||
|
|
3ec4b1de61 | ||
|
|
e6758759f2 | ||
|
|
68ef786450 | ||
|
|
e486c8c94f | ||
|
|
2f70ef71b6 | ||
|
|
28bf192b6b | ||
|
|
da7439af8e | ||
|
|
c0f7320977 | ||
|
|
09b06d1dc7 | ||
|
|
53c9f1b17c | ||
|
|
4573e1295d | ||
|
|
9c2db0f1e9 | ||
|
|
364c5aff97 | ||
|
|
5b2e59c855 | ||
|
|
a9aa88b439 | ||
|
|
91ec1eb4ce | ||
|
|
6e0988d01e | ||
|
|
fecd5e6ba7 | ||
|
|
6f8fd99e11 | ||
|
|
6c66e67742 | ||
|
|
fd34bbf246 | ||
|
|
a9640b9e79 | ||
|
|
5381b1326c | ||
|
|
e4112083e3 | ||
|
|
e4dcbfab28 | ||
|
|
28a24da0c1 | ||
|
|
531a25d578 | ||
|
|
3bb144655a | ||
|
|
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 |
@@ -0,0 +1,24 @@
|
||||
name: 报告问题 / Bug report
|
||||
description: 哪里坏了、报错了,或者和你想的不一样 / Something broke or did not do what you expected
|
||||
title: "[Bug] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: 遇到了什么问题? / What went wrong?
|
||||
description: 一两句话就行,中文英文都可以。 / A sentence or two is enough, in any language.
|
||||
placeholder: 比如:在模型页点扫描,进度停在 30% 就不动了。 / e.g. The scan on the Models page stops at 30%.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: more
|
||||
attributes:
|
||||
label: 截图或报错(可选) / Screenshots or errors (optional)
|
||||
description: 截图可以直接拖进来。 / Drop screenshots here.
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 环境信息(可选) / Environment (optional)
|
||||
description: 在插件里点 设置 → 帮助与反馈 → 复制诊断信息,粘贴到这里。 / In the plugin, Settings → Help and feedback → Copy diagnostics, then paste here.
|
||||
render: text
|
||||
@@ -0,0 +1,2 @@
|
||||
# A blank issue stays possible: the plugin's feedback window opens one with what was written.
|
||||
blank_issues_enabled: true
|
||||
@@ -0,0 +1,13 @@
|
||||
name: 提建议 / Suggestion
|
||||
description: 想要的功能、哪里不顺手、希望往什么方向发展 / A feature you want, something awkward, where the plugin should go
|
||||
title: "[Idea] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: 你希望有什么? / What would you like?
|
||||
description: 随便写,一两句话就行,中文英文都可以。 / Anything goes; a sentence or two is enough, in any language.
|
||||
placeholder: 比如:希望素材库能按标签筛选。 / e.g. Filter the material library by tag.
|
||||
validations:
|
||||
required: true
|
||||
@@ -24,6 +24,10 @@ temp/
|
||||
*.pid
|
||||
*.tmp
|
||||
*.temp
|
||||
*.scan_in_progress
|
||||
*.scan_progress.json
|
||||
.global_scan_in_progress
|
||||
.global_scan_progress.json
|
||||
*.bak
|
||||
*.backup
|
||||
*.sqlite
|
||||
@@ -52,7 +56,22 @@ api/notebooks/
|
||||
# AI Dev Scripts & Temporary text dumps
|
||||
scripts/
|
||||
doctor_code.txt
|
||||
docs/
|
||||
# Only the Markdown of these doc folders is tracked: screenshots, logs and raw test
|
||||
# data stay local (put them under .agents/).
|
||||
docs/*
|
||||
!docs/architecture/
|
||||
docs/architecture/*
|
||||
!docs/architecture/*.md
|
||||
!docs/decisions/
|
||||
docs/decisions/*
|
||||
!docs/decisions/*.md
|
||||
!docs/guides/
|
||||
docs/guides/*
|
||||
!docs/guides/*.md
|
||||
# The README's pictures (small WebP from the isolated demo ComfyUI).
|
||||
!docs/images/
|
||||
docs/images/*
|
||||
!docs/images/*.webp
|
||||
AI_SYSTEM_TEMPLATE.md
|
||||
|
||||
# AI Agents Directory
|
||||
@@ -66,3 +85,4 @@ tests/
|
||||
# Local diagnostic/test artifacts
|
||||
grep_doctor.txt
|
||||
test_folders.json
|
||||
*.bat
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# 开发与维护规范
|
||||
|
||||
适用于本插件的所有修改,包括 Gemini、Codex 和人工维护。目标是让 UX 持续改善,同时让代码职责、状态来源和验证方式保持清楚。
|
||||
用户明确的当前要求优先;确需偏离本规范时,采用最小例外,在交付说明中写明原因、影响和验证。常规实现选择自行处理,不因本规范增加无必要的确认步骤。
|
||||
|
||||
## 0. 每次提交的硬性要求
|
||||
|
||||
以下几条每次提交都要做到,没有“这次改动小”的例外;其余各节是怎么做好的指引。
|
||||
|
||||
1. **结构和文档同步。** 按下表更新文档,与代码放在同一次提交里。新增、删除、改名、拆分源码文件一律算改了模块职责:职责清单同时是文件目录,每个源码文件都要有一行。
|
||||
2. **跑结构检查。** `node tools/check_structure.mjs`,失败不提交。它检查每个源码文件都登记在架构文档里、每个前端模块都能从入口引用到、每个前端文件都能按浏览器的方式解析(`node --check` 对 .js 文件会漏掉浏览器报错的写法),并执行规模阈值(第 2 节):新文件不能一开始就超线,已超线的文件比上次提交只能变短或持平。
|
||||
3. **不留旧实现。** 被取代的模块、样式、文案 key、文档说明同一次提交删掉(第 6 节)。
|
||||
4. **按风险验证**(第 7 节),只提交本任务的文件,没有明确同意不推送。收尾时工作区干净,或在交付说明里列出每个有意留下的未提交文件。
|
||||
5. **测试产物不进仓库。** 截图、录屏、日志、原始测试数据、审计附件放在 `.agents/`(已忽略),不用 `git add -f` 绕过忽略规则。提交的文件里不写本机路径、用户名或 `file:///` 链接,文档之间用相对链接。
|
||||
6. **交付说明**写清:改了什么和为什么、验证结果和没验证到的地方、遗留问题、提交号;用了规范例外就写原因和影响。
|
||||
|
||||
| 改了…… | 更新 |
|
||||
| --- | --- |
|
||||
| 新增 / 删除 / 改名 / 拆分源码文件,模块职责 | `ARCHITECTURE.md` 职责清单,或最窄的 `docs/architecture/` 专题 |
|
||||
| 数据流、接口、持久化格式、安全边界、跨系统规则 | 对应的 `docs/architecture/` 专题;系统图或阅读地图变了才改 `ARCHITECTURE.md` 入口 |
|
||||
| 用户能感觉到的行为 | `CHANGELOG.md`;使用方法变了同步 `README.md` 或 `docs/guides/` |
|
||||
| 长期有效的产品或架构取舍 | `docs/decisions/` |
|
||||
| 反复出现的坑、严重故障 | `.agents/logs/ai_lessons.md` |
|
||||
| 开发规范本身 | 只改本文件;`CLAUDE.md`、`GEMINI.md` 只做阅读入口 |
|
||||
|
||||
普通修复、样式微调不写进架构文档;没有变化的边界不写“本次未改变”。
|
||||
|
||||
## 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 行为线。
|
||||
- 新文件不超过 600 行;写不下说明有两种以上职责,先拆。
|
||||
- 已经超过 600 行的老文件,每次改动后只能变短或持平,不能变长。要在里面加功能,先把一块独立职责搬出去,腾出行数再加。
|
||||
- 不为了达标专门大拆;改到哪个文件,就顺手从它身上拆走一块,让超线文件随日常修改逐步变少。
|
||||
- 语言目录、数据表和样式表不按这个行数计算。不能通过空壳包装、压缩代码、删注释或碎片化文件绕过。
|
||||
- `tools/check_structure.mjs` 按上次提交比对,新文件超线或老文件变长都会失败。
|
||||
|
||||
## 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 的原子写入不能冒充整组文件的事务。
|
||||
- UI 适配不能偷偷迁移用户数据、修改全局配置或改变现有路由与存储格式。确需改协议时同步调用方、兼容策略和测试。
|
||||
|
||||
### 安全底线
|
||||
|
||||
ComfyUI-Manager 收录审核检查的就是这几类口子(2026-10 审 Anomalous_TTS 时逐条指出过)。没有“先这样、以后再补”的例外:
|
||||
|
||||
1. **不执行下载来的或用户文件里的代码。** 数据不用 `pickle`、`eval`、`exec`、动态导入读;配置文件当数据解析。模型文件只读 safetensors / GGUF 的文件头,`.ckpt` / `.pt` / `.pth` / `.bin` 不打开,界面提醒它们可能带代码。
|
||||
2. **下载要能核对。** 只从允许的 https 站点下载,先写 `.part`,核对 SHA-256 后才改名放入;没有可核对的指纹时在界面上说明。下载的东西只当数据用。
|
||||
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。
|
||||
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。
|
||||
5. **用户的文件删除一律进回收站,没有回收站就拒绝;从不覆盖**(同名另存为“名字 (2)”或拒绝)。插件自己产生的临时文件(`.part`、暂存区)可以直接删。
|
||||
6. **只读的接口也按第 3 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。
|
||||
7. **读用户文件要有上限。** 限制读取的大小和条数;读不了或超大的文件跳过并在结果里说明,不让一个坏文件拖垮整个请求。读到的内容进页面只当纯文本(本节第二条)。
|
||||
|
||||
### 资源占用
|
||||
|
||||
ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应该几乎不占资源:
|
||||
|
||||
1. **用户动了才做事。** 不在后台轮询、定时扫描或预加载;确需定时的(扫描进度、跟随选中的节点)只在对应界面打开期间进行,关掉就停。
|
||||
2. **耗时的活放后台。** 大目录遍历、哈希和大量磁盘 I/O 使用已有工作线程/进程路径,不阻塞 aiohttp 事件循环。
|
||||
3. **数据跟着界面走。** 界面打开时才读,关掉就释放;跨次使用的缓存要小、有上限,写清楚何时失效。长列表只渲染看得到的或前几十条。
|
||||
4. **图片用缩略图。** 网格和列表用缓存的缩略图,原图只在看大图或拖到画布时读取;浏览器里不常驻大量原图。
|
||||
|
||||
## 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 更新都往架构入口追加长篇设计说明。
|
||||
- 该更新哪份文档见第 0 节的表。规范以本文件为唯一正文,避免几份要求各自漂移。
|
||||
- 完成前审查差异、死代码、临时文件和空白错误;保留现有文件编码及换行风格。
|
||||
|
||||
**交付自查:** 新职责放对位置了吗?是否只有一个状态来源?每条关闭路径会释放资源吗?迟到请求会写错对象吗?旧代码和旧 CSS 清掉了吗?新文案已本地化吗?用户文件失败时还在吗?新接口会动文件或联网吗,只接受本机请求了吗?接口收了路径吗,读用户文件有上限吗?插件闲着时还占着 CPU、内存或显存吗?测试验证了实际行为吗?
|
||||
@@ -1,353 +1,540 @@
|
||||
# Anomalous Model Browser - Architecture Summary (架构指南)
|
||||
# Anomalous Model Browser Architecture
|
||||
|
||||
This document provides a high-level overview of the Anomalous Model Browser plugin for ComfyUI. It is designed to quickly onboard new AI agents or developers to the project's structure, design philosophy, and critical subsystems.
|
||||
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.
|
||||
|
||||
Workspace lifecycle note: reopening an already initialized Workspace must restore the visible notebook body and reset the hidden recipe body before refreshing data. Hiding the overlay alone is not enough because the next open reuses the existing DOM tree.
|
||||
## Reading map
|
||||
|
||||
The floating trigger follows the browser modal lifecycle: opening the main browser hides the trigger, while every normal close path restores it. The hidden state uses a scoped class with pointer-events disabled; initialization errors keep the trigger available as the recovery entry point.
|
||||
Before implementation, follow [AGENTS.md](AGENTS.md), the canonical development
|
||||
and maintenance rules; its section 0 is required for every commit.
|
||||
[GEMINI.md](GEMINI.md) and [CLAUDE.md](CLAUDE.md) are reading entry points only.
|
||||
|
||||
Origin refresh is an explicit enrichment mode passed into `_enrich_recipe`; it must not mutate the Python list of references with ad-hoc state. Origin refresh writes schema v5, while normal updates preserve previously imported identity/origin records when the current machine cannot re-resolve them.
|
||||
Saved prompts and saved node values are material files (`anomalous_materials`); there is no
|
||||
Material Library page any more. Prompt Studio lists the saved prompts (reading each body
|
||||
through `web/modules/material_prompt_data.js`; list summaries do not contain prompt bodies)
|
||||
and Current node lists the saved values. See the material contract below.
|
||||
|
||||
Recipe identity badges use short, bounded labels in the card layout; detailed explanations belong in the help tooltip. Identity badges and model-name rows must remain shrinkable flex children so localized or imported text cannot expand the recipe card beyond its container.
|
||||
| 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) |
|
||||
| Saved prompts and node values (material files), image parameter details, keeping an output image | [`docs/architecture/material-library.md`](docs/architecture/material-library.md) |
|
||||
| Model Check, provenance hashes, missing-model recovery, or model scanning | [`docs/architecture/model-resolution.md`](docs/architecture/model-resolution.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) |
|
||||
|
||||
Recipe common-parameter summaries use a readable minimum-width responsive grid and an explicit label/value row; labels stay horizontal, while long summary values use a single-line ellipsis with copy preserving the full value. Full expansion belongs to detailed parameter rows, not to narrow summary cards.
|
||||
`README.md` is user-facing documentation and `CHANGELOG.md` is user-facing
|
||||
release history. Neither is the source of truth for internal architecture.
|
||||
|
||||
Availability refresh actions expose immediate busy feedback, an accessible `aria-busy` state, and a recoverable error state; a disabled button without visible progress is not considered sufficient interaction feedback.
|
||||
## System shape
|
||||
|
||||
Identity help uses a click-to-expand inline explanation attached to the badge. The explanation stays in normal card flow with bounded width and wrapping, so it cannot be duplicated by a native `title` tooltip or cover adjacent flex content.
|
||||
|
||||
Recipe origin editing has two supported update paths: complete recipe updates for metadata/workflow edits, and a refresh-only request containing `filename` plus `refreshIdentities`. The latter must clone the stored recipe before enrichment so the refresh action cannot be rejected by complete-recipe validation or accidentally discard the saved workflow.
|
||||
|
||||
Origin edit dialogs locate a saved model reference by stable provenance fields (`node_id`, `widget_index`, category, and saved value), not by display-only fields such as `type`. Localized action labels must use keys that exist in the shared locale catalog; a missing key must never make a visible dialog action appear inert.
|
||||
|
||||
Recipe import matching is a separate, explicit local-recovery action. The recipe detail entry point sends all unresolved references to the hash-resolution batch endpoint, constrained by the reference's model category; the saved author path and filename are never identity evidence. A found local candidate is presentation-only until the user clicks `Apply match`, which updates the corresponding workflow widget through the full-recipe update contract and archives the prior recipe. Exact saved-path availability checks remain a separate lightweight preview operation.
|
||||
|
||||
## Current recipe interaction correction (2026-08-03)
|
||||
|
||||
The recipe detail view keeps Overview, Parameters, and Versions tabs. The Overview contains the model composition blocks; each resolved model name or preview is an explicit entry into the browser model detail view. Model previews are demand-loaded when Overview opens, and returning from model detail restores the recipe detail view. The model browser stores the active grid folder and scroll coordinates before opening detail and restores them when returning to the grid. List-card Append actions await the shared canvas transaction before reporting success or restoring the button state. Export confirmation uses three states (cancel, exclude, include); cancellation must stop the export flow rather than silently becoming an exclusion.
|
||||
|
||||
The Workspace close action is a state restoration boundary. Opening the Workspace records which main-browser panel was visible; closing it hides recipe/notebook content and restores that panel, falling back to the model grid when the previous state is unavailable. All Workspace close affordances use the same cleanup function.
|
||||
|
||||
## Recipe result gallery (2026-08-04)
|
||||
|
||||
Recipe detail has an on-demand Gallery tab for reusing the user's own generated results. Gallery discovery uses a separate `sha256-node-types-v1` signature: it compares the sorted node class composition and count, deliberately ignoring seeds, prompts, model values, and other parameters so ordinary variations still appear. The original `sha256-structural-v1` fingerprint remains the recipe/history integrity fingerprint. Opening a detail view scans at most the newest 200 PNG files below ComfyUI's output directory and reads only bounded embedded `workflow` or API `prompt` metadata; there is no background polling or persistent output index. Clicking a result loads bounded node parameters and shows recipe-versus-image differences; the match itself remains node-only. The main output Gallery refreshes on each open and has no persistent toolbar refresh strip. A chosen result becomes a recipe-owned, compressed WebP cover in `.assets/<recipe-stem>/`, so it survives output cleanup and follows the recipe during package export/import. The original output image remains a local convenience link, never package data or identity evidence.
|
||||
|
||||
## Recipe Parameters & Parameter Notebooks (2026-08-04)
|
||||
|
||||
The "Parameter Notebook" system has been fully integrated into the Workflow Recipe detail view, replacing the static parameters presentation. Rather than existing as a disjoint global list in the sidebar, Parameter Notebooks are now intrinsically linked to their parent Workflow Recipe.
|
||||
|
||||
When a user saves or updates a recipe, the system automatically creates a new, immutable Parameter Notebook capturing the exact generation values of that moment, and associates it with the recipe (`recipe_filename`). In the Recipe Detail view, the "Parameters" tab is now a two-pane interface: a sidebar listing the history of Parameter Notebooks for that recipe, and a main viewer showing the parameters and matching `sha256-params-v1` gallery for the selected notebook. The global sidebar entry for Parameter Notebooks has been removed, solidifying recipes as the primary structural template and parameter notebooks as their concrete instantiation history.
|
||||
|
||||
The Parameters tab keeps runtime-volatile values such as sampler seeds visible by name but redacts their values from summaries, copies, editing, and parameter matching; the raw serialized workflow retains the slot only for ComfyUI compatibility. Common values use readable horizontal label/value cards and preserve full text instead of silently truncating it, and complex arrays like LoRAs are formatted into clean multi-line bulleted lists. The right-hand viewer identifies the active notebook by name and timestamp, and a notebook switch uses a staggered waterfall transition animation to provide clear visual feedback; stale parameter-gallery responses cannot overwrite the newly selected notebook. “New parameter note” clones the selected recipe/snapshot into an editable draft and saves a new immutable snapshot; it never edits or overwrites historical notes.
|
||||
|
||||
Parameter Notebook actions include “Read current and create” and “Apply to current workflow”. Both use the same skeleton preflight: every saved node must have a matching local node by type/shape (with stable-ID/title preference), while extra local nodes are allowed. Read-current refuses to create a draft when the recipe skeleton is incomplete. Apply uses serialized nodes only for matching, then resolves each match back to the live ComfyUI node before reading `widgets` or invoking callbacks; serialized workflow records must never be treated as runtime widget objects. It validates all widget slots before mutation, skips volatile values, writes widget values inside `beforeChange`/`afterChange`, and calls `onWidgetChanged(index, value, previousValue, liveWidget)` so current ComfyUI error-clearing hooks receive the widget object they require. It marks the canvas dirty and restores prior widget values if a callback fails.
|
||||
|
||||
## Node Assistant & Parameter Notebook Integration (2026-08-06)
|
||||
|
||||
The Parameter Notebook system has been extended to act as a contextual preset library within the Node Assistant (`ui_doctor.js`). When a user selects a specific node (e.g., `KSampler`), the Node Assistant automatically queries the backend for all historical Parameter Notebooks across all recipes, filtering specifically for saved nodes that match the selected `type`.
|
||||
|
||||
The backend (`api/parameters.py`) implements a lightweight in-memory cache for notebook parsing to ensure instantaneous UI updates without heavy disk I/O, which can be bypassed via `?refresh=1` for immediate UI synchronization. The filtered presets are rendered hierarchically (Recipe -> Notebook -> Parameter Summary). Applying a preset invokes `applyLocalNodeParameters`, which performs a targeted, single-node widget replacement while preserving the rest of the canvas and safely triggering all required ComfyUI widget callbacks.
|
||||
|
||||
### Topological Prompt Tracing and Dynamic Role Injection
|
||||
Prompt tagging (Positive vs Negative) leverages a robust Topological Backward-Tracing engine (`recipe_parser.js`) rather than fragile regex matching on node titles. During recipe save, the engine starts from known conditioning ports on samplers (including `positive`, `negative`, `conditioning`, `cond`, `uncond`, `model_cond`, etc.) and traces backward through the graph to explicitly tag upstream `CLIPTextEncode` nodes.
|
||||
These topological `role` properties are baked into the recipe's `metadata.nodes`. To seamlessly bridge this structural knowledge to the Node Assistant, `api/parameters.py` performs Dynamic Role Injection—reading the bounded recipe's metadata by `node_id` and injecting the `role` back into the Parameter Notebook response. This ensures the UI accurately displays `[🟢 正面]` or `[🔴 负面]` prefixes without guessing.
|
||||
|
||||
## 1. Project Philosophy (设计理念)
|
||||
* **Zero Frameworks**: No React, Vue, or build tools. Everything is Vanilla JS and CSS for maximum compatibility, minimum overhead, and zero compilation steps.
|
||||
* **Non-Intrusive Integration**: Operates as a floating Gemini-style popover (`#anomalous-container`) mounted via ComfyUI's standard UI extension system. It avoids altering the native ComfyUI canvas except when explicit interaction is required. The floating trigger is mounted before browser panels are initialized and retries initialization on click, so a panel regression or missing optional browser API cannot hide the plugin entry point.
|
||||
* **Strict DOM Obliteration**: Rather than caching complex DOM structures (like `display: none` for large grids), the UI strictly employs `innerHTML = ''` when navigating between folders. This enforces immediate Garbage Collection, crucial for performance when users have thousands of models.
|
||||
* **Offline-First Resilience**: Model metadata is parsed from local `.info` and `.civitai.info` files or extracted directly from `.safetensors` headers via Python `struct`. API calls to Civitai are explicit, user-initiated actions.
|
||||
|
||||
## 1.5 Internationalization and Licensing Boundaries (2026-08-06)
|
||||
|
||||
The extension is a UI-only ComfyUI integration: `NODE_CLASS_MAPPINGS` is intentionally empty, and the project does not provide or install custom nodes. Its runtime is split between Vanilla JS/CSS frontend modules and Python `aiohttp` routes hosted by ComfyUI. The repository currently has no bundled font, image, video, vendor library, dependency manifest, or license file; Civitai, optional DeepL/Google translation endpoints, ComfyUI host APIs, and Pillow/aiohttp are runtime/environment integrations rather than bundled project assets.
|
||||
|
||||
`web/modules/locales.js` is the canonical home for user-facing translation data. The current codebase still contains substantial inline `zh`/`en` branches across `main.js`, Sidebar, Detail, Gallery, Doctor, Notebook, Dialog, Grid, and the optional hash resolver. The first i18n migration snapshot now provides `supportedLocales`, `normalizeLocale`, `resolveLocale`, `formatTranslation`, `translate`, and `createTranslator`. `main.js` and `ui_dialog.js` use this contract; the remaining modules are intentionally unmigrated until their own reviewable snapshots. The refactor must preserve current Chinese and English behavior, keep model names/paths/node types/user data out of locale dictionaries, and keep translated HTML inside the existing `safe_dom.js` boundary. Missing translations must never prevent a panel or action from rendering. Language selection remains the existing zh/en toggle until a later phase introduces a registered locale selector.
|
||||
|
||||
Every i18n or licensing change is a local, reviewable snapshot. The implementation must update this document and `.agents/logs/ai_changelog.md` in the same change, run proportional syntax/runtime checks, and create a local Git commit. Remote pushes require explicit user authorization. The MIT license applies to project-owned code and documentation only; external service content and host-project dependencies retain their own terms.
|
||||
|
||||
The second migration snapshot extends the shared translation contract to `ui_grid.js` and `ui_gallery.js`. Gallery confirmation and cover-selection banners are assembled with DOM nodes and `textContent` rather than translated raw HTML; user-controlled model names remain text content. These modules resolve strings at render time through `translate`, so existing global language changes continue to be reflected without introducing a new locale selector.
|
||||
|
||||
The third migration snapshot extends the same contract to `ui_detail.js`, `ui_doctor.js`, and `ui_notebooks.js`. Detail editing, model selection, Doctor diagnostics, the Node Assistant, the model picker, and Notebook validation now resolve user-facing strings through `translate` with parameter interpolation for counts, paths, model names, and scan status. Dynamic model/path/node data remains outside the locale dictionaries; rich UI state is composed with DOM nodes where translated text replaced previously formatted status HTML.
|
||||
|
||||
The fourth migration snapshot applies the contract to `web/main.js` for preflight workflow import and workflow share-code export/import. These modal surfaces use shared keys for labels, status messages, and count interpolation; the remaining locale conditional in `translateText` only maps the active locale to the backend service's `zh-CN` or `en` code and is not a UI copy branch. `ui_sidebar.js` remains a separate pending snapshot because its scan wizard, settings hub, language toggle, and folder manager require their own key extraction review.
|
||||
|
||||
The fifth migration snapshot applies the contract to `web/modules/ui_sidebar.js`. Sidebar navigation, scan wizard cards, metadata/hash scan controls, model settings, language-toggle refreshes, layout controls, and folder management now resolve user-facing copy through `translate`. Wizard descriptions that intentionally contain trusted formatting remain dictionary-owned rich text; dynamic file names, counts, API errors, and folder data remain runtime values. Locale checks used only for the language CSS class and toggle direction remain functional state logic rather than inline UI copy.
|
||||
|
||||
Licensing and community-delivery work are intentionally deferred. The current local MIT `LICENSE` change is owned by the user's separate Gemini workflow and is not part of the i18n snapshots. Russian localization begins only after the contributor submits a PR. Remote pushes and Issue #9 synchronization remain disabled until the user explicitly authorizes them after other feature work is complete.
|
||||
|
||||
The sixth migration snapshot completes the Stage 2 UI i18n pass for `ui_recipes.js`, `ui_recipe_detail.js`, the supporting Recipe modules, and `web/hash_resolver.js`. Recipe modules now use the shared `translate` contract rather than importing the dictionary directly; the hash resolver uses the same contract for its manual repair summary with a dynamic repaired-count parameter. Recipe workflow/model data remains runtime data and is not added to locale dictionaries.
|
||||
|
||||
## 2. Directory Structure (核心文件结构)
|
||||
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
|
||||
Anomalous_Model_Browser/
|
||||
├── .agents/ # Local, ignored AI handoff material
|
||||
│ ├── logs/
|
||||
│ │ ├── ai_changelog.md # Engineering snapshots, decisions, checks, and handoff notes
|
||||
│ │ └── ai_lessons.md # Curated experience summary and recurring pitfalls
|
||||
│ └── plans/ # Product/design proposals that are not runtime behavior
|
||||
├── __init__.py # ComfyUI Extension Entry Point. Registers nodes and sets WEB_DIRECTORY = "./web"
|
||||
├── api/ # Backend Python API (Modularized)
|
||||
│ ├── __init__.py # Appends all modular routes to server
|
||||
│ ├── config.py # Configuration and paths setup
|
||||
│ ├── metadata.py # Model metadata extraction and parsing
|
||||
│ ├── models.py # Core model listing and routing (API endpoints)
|
||||
│ ├── notebooks.py # Notebooks management logic (Prompt Notes)
|
||||
│ ├── parameters.py # Parameter Notebook CRUD and Gallery logic
|
||||
│ ├── recipes.py # Local Workflow Recipe CRUD and validation
|
||||
│ ├── recipe_packages.py # Bounded ZIP inspection, export, staging, and import commit
|
||||
│ ├── scanner.py # Scanning engine for hash resolution and caching
|
||||
│ └── utils.py # Shared backend utilities and safe path boundary helpers
|
||||
├── scraper.py # Async HTTP client logic & web scraping (Root Level)
|
||||
├── CHANGELOG.md # Version history
|
||||
├── web/
|
||||
│ ├── main.js # Frontend Vanilla JS Entry (Under 1000 lines, mounts modules to prototype)
|
||||
│ ├── hash_resolver.js # Frontend auto-fix engine: scans workflows for missing models
|
||||
│ ├── styles.css # Vanilla CSS with scoped classes (.anomalous-*)
|
||||
│ └── modules/ # Extracted UI logic modules
|
||||
│ ├── ui_sidebar.js # Sidebar and folder tree logic
|
||||
│ ├── ui_detail.js # Model detail panel
|
||||
│ ├── ui_doctor.js # Model Doctor and Assistant panel
|
||||
│ ├── ui_notebooks.js # Prompt Notes editor
|
||||
│ ├── ui_recipes.js # Workflow Recipe cards, search/filter, save dialog, and actions
|
||||
│ ├── recipe_parser.js # Read-only LiteGraph recipe metadata extraction
|
||||
│ ├── recipe_identity.js # Pure recipe model-reference and identity normalization
|
||||
│ ├── recipe_diff.js # Pure bounded semantic version comparison
|
||||
│ ├── recipe_actions.js # Transactional canvas-append actions
|
||||
│ ├── ui_recipe_detail.js # Demand-loaded recipe detail panel and tab rendering
|
||||
│ ├── ui_gallery.js # Fullscreen Gallery Viewer
|
||||
│ ├── ui_grid.js # Main grid and model loading
|
||||
│ ├── graph_splice.js # Transactional MODEL/CLIP chain insertion and rollback
|
||||
│ ├── locales.js # Dedicated multi-language dictionary (i18n)
|
||||
│ └── safe_dom.js # HTML escaping and allowlisted rich-text sanitization
|
||||
├── tests/ # Backend security and path-boundary regression tests
|
||||
├── docs/ # Supplemental documentation
|
||||
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
|
||||
```
|
||||
|
||||
## 3. Backend Architecture (Python API)
|
||||
Modularized into the `api/` package. Registered with ComfyUI's internal `aiohttp` server.
|
||||
All endpoints are prefixed with `/anomalous/`.
|
||||
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.
|
||||
|
||||
### Python Rules of Engagement:
|
||||
1. **Never block the event loop**: File I/O should ideally be offloaded or fast. Large files are handled via `struct` (reading only the first 64KB for metadata), avoiding full reads.
|
||||
2. **Handle missing dependencies gracefully**: Avoid 3rd-party pip requirements if possible.
|
||||
3. **Always Restart ComfyUI**: Any changes to the `api/` package **REQUIRE** a full ComfyUI server restart to take effect.
|
||||
4. **Never use hardcoded backslash strings**: When writing path manipulation code, use `os.sep` or `os.path.normpath()` instead of `replace('\\', '/')`. Editing tools may corrupt escaped backslashes silently.
|
||||
5. **Dynamic Folder Resolution (Config-Driven)**: Never hardcode folder types (like `checkpoints`, `loras`). Always use `api.utils.get_active_folder_types()` to enforce whitelist logic. Folders disabled by the user in `config.json` are completely skipped during `os.walk`, ensuring zero I/O overhead for hidden directories.
|
||||
6. **Strict Path Containment**: Every request path must go through `resolve_folder_subdir()`, `resolve_within()`, and `require_filename()` as appropriate. Checking only for `..` is forbidden: absolute Windows paths, UNC paths, alternate separators, and symlinks can otherwise escape the configured model/output/notebook directory. File-serving endpoints must also enforce an explicit media-extension allowlist.
|
||||
7. **Canonical Configuration**: Runtime UI settings and newly saved API keys live in `api/config.json`. `scraper.py` reads that file first and falls back to the legacy root `config.json` only for backward compatibility. API keys must never be persisted in browser `localStorage`.
|
||||
8. **Atomic Background State**: Set scan state or create the exclusive marker file before launching a worker thread/process. Folder scans use `.scan_in_progress`; global quick scans use `.global_scan_in_progress`; deep missing-model scans use `GLOBAL_SCAN_STATE`, exposed by `/anomalous/scan_missing_models_status`.
|
||||
9. **Private Development Tests**: The local `tests/` directory contains backend and frontend regression tests for maintainers only. It is intentionally ignored by `.gitignore` and must not be included in the installable/plugin Git distribution. Keep these files locally for validation; do not import them from runtime code or move them into the shipped extension bundle.
|
||||
10. **Workflow Recipes Are User Data**: Recipes live only in `ComfyUI/user/default/workflows/anomalous_recipes`, never in the extension repository. `GET /anomalous/recipes` returns card metadata only; the graph is fetched separately through `GET /anomalous/recipe_full`. Recipe writes are bounded, validate local-only data-image thumbnails and contained output-image references, validate graph node IDs/widget bounds/link endpoints before writing, and use atomic replacement. Successful save, update, and restore responses include a compact integrity receipt (node/link/group counts and the persisted workflow fingerprint). A bound output stores a compressed cover plus a local source reference; it never copies an arbitrary path supplied by the browser. Export packages strip machine-local `source_image` paths, include the union of current and selected-history snapshot assets, and preserve imported model identity records independently from transient local availability. Replacement import stages recipe/assets/history and restores the prior set if the commit fails. `recipe_packages.py` accepts only bounded ZIP packages with `manifest.json`, `recipe.json`, declared contained WebP assets, and optional bounded history. Import is inspect-then-commit, rejects traversal/symlinks/archive bombs/checksum failures, never installs code or dependencies, and stages before atomic rename.
|
||||
## Ownership map
|
||||
|
||||
### NON-NEGOTIABLE: Model Sidecar & Cover Lifecycle / 模型伴生文件与封面生命周期死规矩
|
||||
* `.civitai_bak.*` is a persistent restore source created from a real Civitai download. Setting a custom cover MUST modify only `.preview.*`; it MUST NOT delete, overwrite, or repurpose `.civitai_bak.*`.
|
||||
* Reset cover priority is fixed: restore `.civitai_bak.*` to `.preview.*`; otherwise remove `.preview.*` only when a bare original cover (`model.png`, etc.) can take over naturally. If the active `.preview.*` is the only image, preserve it and return a visible warning instead of causing irreversible cover loss. Reset does not silently redownload from the network.
|
||||
* Physical rename means **move/rename** every recognized sidecar, including `.civitai_bak.*`, to the new model stem. It never means deleting the Civitai backup. Model deletion may clean sidecars only after the selected main model was successfully deleted.
|
||||
* Main model extensions (`.safetensors`, `.ckpt`, `.pt`, `.bin`) are never sidecar suffixes. A cleanup operation MUST NOT delete a same-stem model with another extension. If such a sibling survives, stem-keyed sidecars are ambiguous and must be preserved for it.
|
||||
* Performance boundary: sidecar operations use centralized immutable suffix tuples and a constant number of exact-path checks. Do not use `os.walk`, directory-wide globbing, full-directory indexing, hashes, or network calls for rename/delete/reset. This keeps the operation independent of model-folder size.
|
||||
* Preserve the product-language distinction in documentation and UI: **delete cleans sidecars; rename migrates sidecars**. Never describe both operations as deleting or "taking away" the files.
|
||||
This map, with the topic documents, is also the file directory: every source
|
||||
module under `api/`, `web/` and `web/modules/` and each root Python file is
|
||||
named with its owner. `node tools/check_structure.mjs` checks this. Styles are
|
||||
covered by the `styles.css` manifest.
|
||||
|
||||
### NON-NEGOTIABLE: Lossless Performance Boundaries / 无损性能优化死规矩
|
||||
* Metadata and embedded safetensors-header hashes may be cached only with a bounded cache whose key includes the real path and physical file signatures (`size`, `mtime_ns`, and `ctime_ns`) for the model and both metadata sidecars. Callers receive independent copies; cached mutable dictionaries/lists must never be exposed directly.
|
||||
* Preview URLs use the preview file's stable nanosecond modification time as their cache version. Never append `Date.now()` or another per-request random token during ordinary listing: doing so disables the browser cache and redownloads unchanged media. A changed cover must still produce a changed URL.
|
||||
* Folder listing must inventory a directory once with `os.scandir()` and preserve the established preview priority (`.preview.*` before bare media). Do not perform one `exists()` sequence per model when the directory inventory already contains the answer.
|
||||
* Recursive directory walks, metadata parsing, and other potentially large disk operations must run through `asyncio.to_thread()` rather than blocking ComfyUI's aiohttp event loop. This improves responsiveness without skipping files.
|
||||
* Large grids keep the complete ordered result set but create cards in bounded animation-frame chunks. Images use native lazy loading/async decoding; videos delay their source until near the viewport and retain the configured autoplay/hover behavior once activated. A new folder request must cancel the previous request and stale render generation.
|
||||
* Card-quality choice is deliberately user-facing but bounded to two understandable modes: `balanced` requests a derived longest-edge-512px WebP for static grid covers, while `original` serves the source cover. Detail pages continue to use the original in both modes. Derived thumbnails live only in ComfyUI's temporary area, are keyed by the source real path plus physical signatures, and are capped at 256 MiB with oldest-accessed eviction. They MUST NOT modify, replace, rename, or sit beside the user's source cover; unsupported/animated images and any generation failure fall back to the original.
|
||||
* Closing the browser immediately aborts listing work, disconnects observers, pauses all media, and releases grid video/audio sources. Lightweight card DOM may remain warm for 90 seconds for a smooth reopen, after which cards and retained model payloads are released automatically. Folder replacement must stop old media before removing DOM nodes. Non-grid panels retain their established UI state, so their paused media is rebuilt only by the panel's existing lifecycle rather than being left visibly broken on reopen.
|
||||
* Model Doctor batch resolution is an I/O optimization only. Provenance-rich workflows skip the redundant full filename-to-hash cache refresh; legacy workflows missing injected provenance still refresh it for compatibility. Requests are grouped by the exact required model-type tuple so each group is scanned once, while every item still uses the same cryptographic hash, exact byte-size, category constraint, conflict rejection, and ambiguity rules as `/anomalous/resolve_hash`. Batch failure must fall back to the single-item endpoint; batching must never introduce filename/path evidence.
|
||||
* Preview resolution should try contained exact relative paths first and walk the library only for unresolved basename fallbacks. Exact-path lookup here locates a preview for a model value already supplied by ComfyUI; it is not Model Doctor identity discovery.
|
||||
### Backend
|
||||
|
||||
## 4. Frontend Architecture (Vanilla JS)
|
||||
Located in `web/main.js` (Under 1000 lines, fully refactored into ES Modules). Wraps its logic inside `app.registerExtension({ name: "Anomalous.ModelBrowser", ... })`.
|
||||
- `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. A model's information
|
||||
has three layers, the user's first: `<model>.anomalous.json` (what the model editor set; only
|
||||
the editor writes it), the scan result (`<model>.info`, or another tool's `.civitai.info`
|
||||
with Civitai ids; `id: -1` means inferred from the file) and the file itself. Metadata
|
||||
reports `info_source` (`civitai` / `local` / empty) and `user_fields`. A cover is Civitai's
|
||||
only while it is byte-identical to `<model>.civitai_bak.*`; any other cover is the user's,
|
||||
and no scan replaces or deletes it.
|
||||
- `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/model_folder.py` shows a model file in Explorer / Finder (by type, folder index and
|
||||
file name, never a path; a POST, so only this computer may ask);
|
||||
`api/models.py` is a compatibility facade; `api/model_constants.py` holds the
|
||||
model, media and sidecar extensions they share. `api/model_type_listing.py` lists a
|
||||
whole models folder with its subfolders for the models page's type chips.
|
||||
- `api/scanner.py` and `scraper.py` own scan orchestration and enrichment; both scan
|
||||
routes map request fields to scraper switches in one place (`_scraper_flags`).
|
||||
`civitai_client.py` is the scraper's network side (version by hash, model page, media),
|
||||
telling "Civitai does not know the file" from "Civitai could not be asked".
|
||||
`api/scan_report.py` turns the scraper's per-model report lines into one result per
|
||||
scan: kept as the last scan (`/anomalous/last_scan`), recorded in the activity log, each
|
||||
model located so the browser can open it (`/anomalous/scan_model`).
|
||||
`api/kept_images.py` says what each output image was kept as, for the gallery's star: the
|
||||
Workflow Recipe, combo or saved prompt that names it as its source image.
|
||||
`api/scan_summary.py` counts models matched on Civitai, unmatched and not yet scanned
|
||||
for the scan page and lists the last two, by the same sidecar rules the scraper uses
|
||||
(`sidecar_info`, `is_unmatched`, `unmatched_reason` in `model_identity.py`); it also
|
||||
lists the model files in other formats, which scans never read, so the page can say so.
|
||||
- `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_constants.py`
|
||||
holds the size and count limits shared by recipes, parameters and materials.
|
||||
- `api/recipe_packages.py` owns bounded inspect-stage-commit package handling.
|
||||
- `api/parameters.py` owns Parameter Notebook persistence and lookup.
|
||||
- `api/notebooks.py` owns combo (搭配, formerly 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/node_material.py` saves one canvas node's current values as a
|
||||
`node_parameter_selection` material (the current-node panel's "save").
|
||||
- `api/local_guard.py` decides which requests only this computer may make (every change under
|
||||
`/anomalous/`; other devices may look), as one middleware; see backend.md.
|
||||
- `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 and the base model a safetensors header
|
||||
tells, shared with the standalone scanner (which runs as its own process, so the API
|
||||
never imports `scraper.py`).
|
||||
- `recycle_bin.py` is the only way the plugin deletes the user's files (models, covers,
|
||||
outputs, recipes, materials, notes, audio): to the system Recycle Bin, together, or not
|
||||
at all (`TrashUnavailable` on drives without one). `api/trash.py` is the API's import of it
|
||||
plus the message shown when nothing was deleted. The plugin's own temporary files, caches
|
||||
and bounded recipe history are still removed directly.
|
||||
- `api/audio_catalog.py` owns the generated-audio side: the output-audio history,
|
||||
temp Preview Audio results (listed, and copied into `output/audio/` on save),
|
||||
deletion, and streaming files from the output and temp folders. Characters and
|
||||
their reference clips belong to the Anomalous_TTS node and come from its routes.
|
||||
- `api/audio_metadata.py` reads the ComfyUI `prompt` comment from FLAC and
|
||||
Ogg/Opus files (read-only) and extracts the TTS node's speech, sample and seed
|
||||
for the audio gallery.
|
||||
- `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.
|
||||
|
||||
### The Modular Extraction Strategy (模块化拆分架构)
|
||||
We have successfully transitioned from a monolithic `main.js` to a modular ES architecture.
|
||||
Instead of fragmenting the class scope and losing context, we extracted all UI panels into `web/modules/` and bound them back to the `AnomalousBrowser.prototype`.
|
||||
1. **Modules**: All major UI components (`ui_sidebar.js`, `ui_detail.js`, `ui_doctor.js`, `ui_notebooks.js`, `ui_gallery.js`, `ui_grid.js`) are decoupled ES modules.
|
||||
2. **Shared State**: Everything continues to live on the `AnomalousBrowser` class instance (`this.xxx`). No complex context passing required.
|
||||
3. **TOC (Table of Contents)**: The top of `main.js` contains a TOC mapping out the extracted module bindings.
|
||||
- `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 (pointer-capture drag,
|
||||
pre-mount positioning to avoid a flash, `anomalous_trigger_pos_v3` persistence);
|
||||
the unsaved default position is defined in CSS; `entry_controls.js`
|
||||
owns entry mode, trigger size/style normalization, viewport clamping that keeps
|
||||
the button out of the sidebar dock, and saved-position storage
|
||||
(`isValidSavedTriggerPosition`); `api/__init__.py` sends `Cache-Control: no-cache`
|
||||
for this plugin's static files so a normal refresh revalidates them (modules are
|
||||
imported without `?v=` query strings, which would create second module
|
||||
instances); and `interface_settings.js` owns language and theme preferences.
|
||||
- `ui_domain_switcher.js` stores which side is active (image or audio: which list the
|
||||
list column shows, which guide "!" opens); the rail's pages switch it through
|
||||
`ui_shell_nav.js`. `browser.switchAudioTab` is the single entry for the audio panels
|
||||
(rail pages, the audio list's own entries) and `hideAllPanels` stops audio playback.
|
||||
- `audio_engines.js` owns the audio page's one speech engine, GPT-SoVITS through
|
||||
the separate Anomalous_TTS node pack (docs/decisions AD-016), detected at runtime
|
||||
through `/object_info/<node class>`. Characters are normalised into one
|
||||
voice-group shape (`node_value` = what the node's `character` widget takes), so
|
||||
cards, sidebar and the Voice-over view share one implementation. While the pack is
|
||||
missing the studio shows install steps instead of cards; nothing else depends on it.
|
||||
Its data comes only from the node's HTTP contract, version 13
|
||||
(`/anomalous_tts/characters`, `/audio`, `/settings`, `/status`, the pretrained
|
||||
download and import routes; the node repo's `docs/INTERFACE.md`); Anomalous never
|
||||
reads or writes its model folders. No call names a path on the computer: the
|
||||
storage place, other folders and package sources are set only in the node's
|
||||
settings file (`status.settings_file`), and imported files are always uploaded. `loadGptSovitsStatus` caches the setup status
|
||||
with the engine data (null for a node without `/status`, which hides setup and import). The character list is a
|
||||
summary without file lists; `fetchGptSovitsCharacter` gets one character's files.
|
||||
Engine presence and loaded voice groups are cached (`MAX_AGE_MS`) so re-renders
|
||||
from sidebar clicks, domain switches and the studio + sidebar pair cost no
|
||||
requests; `invalidateEngineCache()` drops them after a save or an added voice,
|
||||
and `invalidateEngineCache({ rescan: true })` (Refresh button) also makes the node
|
||||
re-read its folders (`?refresh=1`).
|
||||
`ui_audio_tts_editor.js` edits a character's emotion references through that API:
|
||||
it opens at once, loads the character's audio list in the background (save waits
|
||||
for it), and keeps settings fields it does not know.
|
||||
`tts_setup_api.js` holds the other calls (pretrained downloads, chunked
|
||||
upload, inspect, commit, discard) and the pure import-form rules (`importKind`,
|
||||
`pickWeights`, `nameConflict`, `textFromFile`, `buildImportBody`, `importProblem`,
|
||||
`rowState` / `sectionState` for the form's colours,
|
||||
`setupSummary`, `pretrainedReminder`, `missingForLanguage`, `formatSize`); no DOM.
|
||||
`ui_tts_setup.js` is the GPT-SoVITS settings dialog, opened from the sidebar
|
||||
footer (`setupAttention` gives that entry its dot). Missing pretrained files only get a dismissable dot
|
||||
when the studio's characters need them (dismissed ids in `localStorage`; a newly
|
||||
needed file brings it back); missing packages get a red one. Inside: the storage
|
||||
place and other places with characters (shown, not changed), pretrained files
|
||||
and package sources, packages, and the settings file where folders are changed.
|
||||
It polls the status only while a download runs and it is open.
|
||||
`ui_tts_import.js` is the import window. It first asks
|
||||
what the user has (`mode`: `single` = one card led by its checklist, `batch` = a
|
||||
drop area then one card per draft, one unfolded at a time, `add` = files for an
|
||||
existing character, also opened straight from a card's "Add files"), and runs a
|
||||
spotlight tour once per screen ("?" replays it). It owns the drafts (characters being built; `target` = files added to an
|
||||
existing one), the unassigned tray, the characters waiting for the next batch
|
||||
(a large add opens one batch; the next opens on request or once the batch is
|
||||
imported), every file row (in exactly one draft's `rows`; a row keeps its own
|
||||
name in `original` and gets a clash-free `name` in a draft, sent to the node as
|
||||
the file's `name`), a debounced inspect per draft, and imports the ready drafts
|
||||
one after another (each commit on its own, without the rows `leftOut` names;
|
||||
closing discards uncommitted uploads). `draw()` rebuilds the cards on structural
|
||||
changes, `refresh()` only repaints status, so typing never loses focus.
|
||||
`ui_tts_import_uploads.js` sends browser files in chunks, three at a time, once
|
||||
they are in a draft (tray files wait); `ui_tts_import_player.js` plays one clip
|
||||
at a time for comparing (from memory).
|
||||
`tts_import_groups.js` holds the pure rules: which folders are never taken
|
||||
(`skipFolder` / `isPackage`: Python environments and base models anywhere, a
|
||||
package's program and training folders only inside a package, never the chosen
|
||||
folder), which draft a file goes to
|
||||
(`groupFiles`: a folder with one character's weights and clips is that character;
|
||||
other weights by stem; other files follow the character folder, the weights in
|
||||
their nearest folder, then folder names or file-name prefixes; unclaimed `.list`
|
||||
files go to every character), how an add splits into groups and batches
|
||||
(`planGroups`, `splitBatch`), new names for files that would clash inside a
|
||||
character (`clashFreeNames`), which files are left out (`leftOut`: clips outside
|
||||
3–10 s and their line files), where a draft stands, and its checklist (the next
|
||||
step first). `ui_tts_import_sources.js` brings files in: the browser's dialogs and
|
||||
drops, all uploaded; every source gives folders starting with the chosen one,
|
||||
says which folders were left out, and confirms a very large upload. `ui_tts_import_draft.js` draws a draft's card
|
||||
(name in the header, steps left, the checklist with the next step's button and
|
||||
the chosen weights, one line per clip with its owner picker, the first clips with
|
||||
"show all", left-out files folded, line files, language) and the unassigned card
|
||||
(one folded line per folder when large), and builds each row's elements once, so
|
||||
they survive redraws and moves; `ui_tts_import_screens.js` holds the fixed
|
||||
screens (the first question, the batch drop area, the "add more" menu) and the
|
||||
tour steps. `ui_tts_file_drop.js` reads OS drops (walking dropped folders, only
|
||||
files an import can use, up to 5000) for the studio, the sidebar and the
|
||||
workbench.
|
||||
- `audio_node_targets.js` is the single table of canvas nodes the audio studio
|
||||
writes into (the character widget and the script widget of
|
||||
`AnomalousTTS_CharacterSpeech`).
|
||||
`planVoiceDrop` decides every character drop and names the reason for each refusal;
|
||||
unlisted nodes are refused, never matched by widget name. Supporting a node
|
||||
means adding one entry plus a test.
|
||||
- The rail's Voices entry has two views, switched by `ui_voice_tabs.js` at the top of
|
||||
each and sharing the character list: Characters (shell page `voices`) and Voice-over
|
||||
(shell page `script`; `ui_shell_nav.js` keeps the rail on Voices and reopens the view
|
||||
used last).
|
||||
`ui_audio_studio.js` owns the Characters view: the character cards, preview playback,
|
||||
tag copying, dragging a card header onto a node through the shared `bindMaterialDrag`
|
||||
with `targetHint`/`rejectHint` telling the user what releasing does, and each card's
|
||||
"Voice-over" button (`owner.openScript(group)`).
|
||||
`ui_script_page.js` owns the Voice-over view: it loads the characters and mounts the
|
||||
script director, or shows the install steps or the way to import one when there is
|
||||
nothing to voice yet. A character picked in the list speaks there; the audio
|
||||
gallery's "voice it again" opens a file's lines with its character
|
||||
(`owner.openScript(null, { speech, subfolder })`).
|
||||
`audio_script.js` holds the pure script rules: splitting, bundling
|
||||
(`buildScriptPackage`, with `[take:N]` for retakes), combo value
|
||||
matching, and `buildTtsPrompt` (a GPT-SoVITS script as a ComfyUI API prompt
|
||||
saving to `output/audio/<character>/`).
|
||||
`ui_script_director.js` owns the script director, the Voice-over view's body: one
|
||||
character, an emotion chip row per line card (with the chosen emotion's reference clip;
|
||||
none when the character has only its main voice), editing buttons shown on hover, and a
|
||||
bundle that is generated in the view, or, under "Into your own workflow", pushed to the selected/only target node,
|
||||
dragged onto one (writing the character and script widgets together, one Ctrl+Z
|
||||
step) or copied. The page feeds it the voice groups after each fetch.
|
||||
It puts the cards on one side and generating on the other: beside
|
||||
them once the page is wide, below them when it is narrow (docked browser).
|
||||
`ui_script_run.js` is the director's "Generate" section (the bar with the result and
|
||||
a way to the audio gallery; language and speed in view, the sampling numbers folded
|
||||
under "fine-tuning" with plain names): it runs
|
||||
the script without the canvas, plays the result, retakes the whole script
|
||||
(new seed) or one line (`[take:N]`, the node caches the rest), and saves a
|
||||
character's language, speed and folded sampling parameters to its `defaults`
|
||||
(only values that differ from the node's own defaults, which it reads from
|
||||
`/object_info`; needs Anomalous_TTS interface 11).
|
||||
`ui_tts_pronunciation.js` edits a GPT-SoVITS character's pronunciation table
|
||||
(the node's `replace` setting) from its card; each row can be heard as it reads
|
||||
now and as replaced, through a Preview Audio run, and saving keeps every other
|
||||
settings field.
|
||||
`audio_tts_run.js` queues one API prompt with this page's client id and
|
||||
follows it (websocket status, `/history` result, cancel = queue delete or
|
||||
targeted `/interrupt`); it never touches the canvas.
|
||||
- In the audio domain the header **!** opens `AUDIO_USAGE_GUIDE` (a how-to, see
|
||||
`docs/guides/audio-studio.md`) instead of the visual update guide.
|
||||
- `ui_audio_sidebar.js` owns the audio navigation and the active audio filter:
|
||||
characters grouped by language, groups folded
|
||||
in `localStorage`, a character unfolds into its clips (a click plays the clip
|
||||
through its studio row, switching the studio to that character when needed), a
|
||||
search box from six characters on, a red dot for characters that need a look,
|
||||
the GPT-SoVITS settings entry at the bottom and file drops on a character. Canvas drags go through `audio_voice_drag.js`, shared with the
|
||||
studio cards.
|
||||
- `ui_audio_gallery.js` owns the generated-audio history (output folder): playback,
|
||||
seeking, search, paging, download and deletion, showing the speech/voice/seed
|
||||
recorded in each file, plus a section for unsaved temp previews that can be
|
||||
copied into `output/audio/`.
|
||||
- `ui_sidebar.js` assembles the browser window (rail, list column, header, page panels;
|
||||
`remote_view.js` adds the header's "view only" chip when opened from another device)
|
||||
and renders the model folder list. `ui_shell_rail.js` owns the left icon rail: one
|
||||
entry per page, the tool slots and the settings slot. `ui_shell_nav.js` owns page
|
||||
navigation (`goTo`): the domain each page needs, which pages have a list and whether
|
||||
it is open (remembered per page; closed and overlaying below 760 px), the header's
|
||||
page title, and the page reopened next time. `ui_home.js` owns the home page (task
|
||||
cards, first steps). `ui_shell_frame.js` owns dragging, resizing and keeping the
|
||||
floating window on screen; `ui_scan_watch.js` polls scan status for the rail's scan
|
||||
button, the progress panel and the model reload afterwards.
|
||||
The activity log (docs/decisions AD-018): `api/activity_log.py` keeps the entries
|
||||
(newest first, bounded, in the ComfyUI user folder), records this plugin's and
|
||||
Anomalous_TTS's successful write requests in a middleware, and serves
|
||||
`/anomalous/activity` (a handler may attach what it changed, `request[ACTIVITY_DETAIL]`;
|
||||
scans record themselves when they end); `activity_canvas.js` records canvas changes by comparing a
|
||||
snapshot taken when the user presses inside Anomalous with the canvas when they
|
||||
next press or type outside it (`activity_diff.js` holds the pure snapshot and
|
||||
difference); `activity_log.js` is the client and the words for each entry;
|
||||
`ui_activity.js` renders the activity page and the home page's recent list;
|
||||
`canvas_undo.js` keeps what each canvas entry did, in full, while the page stays open,
|
||||
and undoes an entry from the log while nothing has changed it since (entries that
|
||||
removed nodes or rewired existing ones are never offered).
|
||||
`ui_settings_hub.js` owns the display preferences (view mode, scale, atmosphere, window
|
||||
layout) and their application, the gear and language redraws; `ui_settings_page.js`
|
||||
is the settings page the gear opens (a tool page with Back): look and language, model
|
||||
cards and memory with the card image cache, folders, workflow fingerprints, opening
|
||||
mode and window, help; `ui_feedback_dialog.js` is the feedback window (Home and the
|
||||
settings page open it): one text box, then `feedback.js` opens a GitHub issue with it in
|
||||
the browser's language, the environment folded at the end when attached (versions and
|
||||
hardware only, never a path or ComfyUI's command line), or copies the environment;
|
||||
`ui_rail_tools.js` owns the rail's tool buttons (scan, doctor, current node);
|
||||
`ui_browser_navigation.js` owns shared panel hiding/cleanup and workspace return,
|
||||
including Esc on the workspace panel (`nbPanel`, below the header and right of the
|
||||
rail; the list column steps aside while it is open).
|
||||
`ui_scan_page.js` is the scan page (a tool page like the doctor): counts, the scan
|
||||
button, a card for how the next scan goes (opening the scan settings page), the last
|
||||
scan's summary; each count and the summary open a
|
||||
list page from `ui_scan_lists.js` (not scanned, unmatched, other formats, the last scan's
|
||||
models) with Back, a button for the whole list (scan these / look them up again) and rows
|
||||
that open a model, whose Back returns to the list, or scan it;
|
||||
`scan_results.js` holds the words for scan results, shared with the activity page.
|
||||
`scan_runner.js` starts and follows scans (every folder, picked or listed models, or one
|
||||
model from its card), shows the result and refreshes node drop-downs, hashes and the
|
||||
grid afterwards; folder visibility/order lives in
|
||||
`ui_folder_manager.js`.
|
||||
`scan_progress.js` owns the scan progress panel
|
||||
(`updateScanProgress` / `finishScanProgress` / `failScanProgress`): inside the scan
|
||||
page while it is shown (`setScanProgressHost`), floating at the bottom right otherwise.
|
||||
`shortcut_controls.js` owns the open-browser and Prompt Studio keyboard
|
||||
shortcuts (the second keeps its old "materials" ids), their fallback when ComfyUI's keybinding does not fire, and the
|
||||
settings control that opens ComfyUI's keybinding editor.
|
||||
- `ui_model_sources.js` renders the Models page's Sources view (the last type chip): where each model is downloaded, for the open workflow or every model, with each link editable and saved in place; `model_source_links.js` owns its data and actions (collecting the workflow's models, resolving them here, saving a link to the model's information and the workflow, the canvas note and the clipboard list).
|
||||
- `ui_gallery_card.js` builds one output image's gallery card (viewer, workbench, cover pick, drag, star, delete);
|
||||
`gallery_card_menu.js` is its right-click menu (insert as a Load Image node reading the output file, parameters);
|
||||
`ui_keep_menu.js` is the star's keep menu (also the workbench's Keep): the whole workflow, the combo or the prompts,
|
||||
each saying where it goes, opening what was kept already or removing it (to the Recycle Bin through the recipe,
|
||||
notebook or material delete route); `image_keep.js` does the keeping (an image as a combo,
|
||||
its prompts as a saved prompt, reading what was kept) without DOM.
|
||||
- `ui_combos.js` renders the Combos page's list (搭配: a main model, LoRAs and a prompt, formerly Prompt Notes): search,
|
||||
New, and a card per combo with its model's cover and Put on canvas; a card opens the combo's editor, and dragged onto the
|
||||
canvas (`material_drag.js`) becomes a new group of nodes where it is dropped. Combos keep the notes' files.
|
||||
- `detached_graph.js` lays a workflow on a graph of its own, off the canvas, under an id of its own: ComfyUI keeps widget
|
||||
values by graph id, so a copy under the canvas's id would write its values onto the canvas.
|
||||
- `recipe_save.js` saves Workflow Recipes for the canvas save and for an output image's workflow (the gallery's keep menu,
|
||||
moving whole workflows kept as materials), laying an image's workflow on a canvas of its own to summarise it.
|
||||
- `ui_apply_receipt.js` is the receipt of values written to a node from a panel (Current node's parameters) with its Undo. `node_material_actions.js` owns prompt envelope extraction (`extractMaterialPromptEnvelope`) and the node writes shared by those panels and prompt drops.
|
||||
- `ui_update_guide.js` and `update_guide_data.js` own the non-intrusive update guide modal (accessible via header button `#anomalous-update-notice-btn`, the Help modal and Home's "What's new"; the current guide's ID and steps live in `update_guide_data.js`) with full bilingual localization. `ui_spotlight_tour.js` provides the interactive spotlight mask tour (`startSpotlightTour`), gliding smooth focal box highlights across the rail's pages and tools with directional tooltip cards and keyboard navigation; steps whose target is not on screen are skipped. The browser tour goes in the order the pages are used (rail, Gallery and what its ☆ keeps, Workflows, Combos, Prompts, then the tools), with its text in `locales.js`. Other views pass their own `steps` (text from locale keys) and an optional `onClose`; the GPT-SoVITS import window does.
|
||||
- `tool_registry.js` holds the tool icons shared by the rail and Home.
|
||||
- The rail's tool slots (`ui_rail_tools.js`, fixed) hold scan, doctor and current node; settings sits at the rail's bottom. The rail's Prompts entry opens Prompt Studio beside the canvas (the browser folds away and comes back when it closes). There is no toolbox: the other tools open from their pages (workflow share codes on Workflows, Model Sources as a view of Models, opened from the doctor too, translation in Current node).
|
||||
- `ui_model_types.js` owns the models page's type chips (one per models folder, with its count) and
|
||||
`owner.modelScope`, what the grid lists: a whole type, or one list folder shown as a crumb. The
|
||||
grid's cards set `currentType/PathIdx/Subfolder` to their own model's folder (`focusModel`),
|
||||
which the editor, the scanner and "add to canvas" read.
|
||||
- `model_source.js` shows where a model's information came from: the card badge (marked only
|
||||
when inferred from the file, ≈, or not scanned yet) and the detail header's source line with
|
||||
the fields the user set.
|
||||
- `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 (the file card with Show in folder in `model_location.js`), `ui_model_editor.js` owns metadata editing, and
|
||||
`ui_model_selector.js` owns advanced selection. `ui_gallery.js` (cards in
|
||||
`ui_gallery_card.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` and `ui_notebooks.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 the Combos workspace (its list or one combo's editor,
|
||||
with Back to the list) and combo 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, and unified dark slim scrollbar ergonomics with complete bilingual dictionary coverage in `locales.js`), and
|
||||
`notebook_canvas.js` puts a combo on the canvas as a new wired group of nodes (following the pointer, or at a dropped
|
||||
card's position; Esc takes a following group off again); it never changes nodes already there. Combos are a rail page. `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).
|
||||
`prompt_boxes.js` is the one place that finds prompt boxes on the live canvas: a
|
||||
multiline STRING input by the node's ComfyUI definition, whatever it is called; its
|
||||
role is the box's own name (positive / negative) or else the wiring (outputs followed
|
||||
to an input named positive / negative). It also plans which box takes which text and
|
||||
finds a box's opposite-role partner on the same sampler. `prompt_drop.js` is the
|
||||
prompt drag on the canvas: boxes outlined by role, the box under the pointer as the
|
||||
target, the text a release writes previewed over it (and its partner), and a hint
|
||||
saying what release writes where. `node_material_actions.js`
|
||||
extracts prompt envelopes without model file paths and writes them (`fillPrompt`);
|
||||
text never crosses roles unless the user picked the box; writes are one undo step.
|
||||
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_models.js` owns the overview's model list (preview, presence under the saved name,
|
||||
note, download page; finding missing models is Model Check's) and the recipe cover,
|
||||
`ui_recipe_metadata.js` owns inline persistence, and `ui_recipe_detail_dom.js` owns
|
||||
the DOM/copy helpers shared by detail subviews. `recipe_identity.js` derives model
|
||||
references from native loaders plus a table of verified all-in-one loader layouts
|
||||
(`ALL_IN_ONE_LOADER_SPECS`, mirrored in `api/recipe_schema.py`); other third-party
|
||||
widgets stay parameters. References are keyed by
|
||||
`(node_id, widget_index, category, saved_value)`. `recipe_parser.js` summarizes
|
||||
models, LoRAs, samplers and prompts (linked prompt nodes before embedded loader
|
||||
prompts) and keeps the summary in step with widget edits using the same node rules.
|
||||
`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 (dock side, width, Esc);
|
||||
`ui_prompt_workbench.js` fills it: the top bar (tags or text view, the meanings' language) and
|
||||
saving a box as a card. `ui_prompt_target.js` decides what is edited, the prompt boxes of
|
||||
the prompt node last selected on the canvas or a positive / negative draft, follows the
|
||||
selection and the boxes' text, and owns the studio's Undo; `ui_prompt_box_editor.js` is one
|
||||
box as tags (select, weight, edit, remove, reorder, drop, type, translate to English) or text.
|
||||
`prompt_tags.js` splits and joins a prompt's tags keeping their separators, and
|
||||
`prompt_gloss.js` looks up and keeps the tags' meanings in the picked language. `ui_prompt_source_deck.js`
|
||||
owns the card list (three built-in cards and the saved prompts), search, new-card form,
|
||||
library sync and renaming / deleting a saved prompt; `prompt_material_source.js` reads
|
||||
saved prompts as cards and saves one; `ui_prompt_card_popover.js` owns the card preview (hover
|
||||
corridor, pin, Copy / Add / Rename / Delete); `prompt_card_drag.js` lets a card or a box be
|
||||
dragged out of the drawer onto a canvas prompt box or empty canvas (through `material_drag.js`
|
||||
and `prompt_drop.js`), passing through inside the drawer so the boxes still take it.
|
||||
`prompt_composition.js` holds the starter cards, composes a saved plan's text and names and sorts prompts.
|
||||
- `ui_node_prompts.js` is the current-node panel's prompt boxes: role and text, with
|
||||
Edit in Prompt Studio. The studio uses `ui_lifecycle.js` for global listeners, request
|
||||
cancellation and resize cleanup. Translation requests go through `translation_service.js`.
|
||||
- `ui_dialog.js` owns the plugin's own alert / confirm / prompt dialogs
|
||||
(`anomalousAlert`, `anomalousConfirm`, `anomalousPrompt`), used instead of the
|
||||
browser's native ones. `ui_prompt_toast.js` is the short toast (optionally with an
|
||||
action, such as Undo after a drop) shared across the plugin.
|
||||
- `canvas_history.js` asks ComfyUI's Ctrl+Z history for a step after each canvas write
|
||||
Anomalous makes (applied values, drops, created or inserted nodes, recipes): ComfyUI
|
||||
takes steps on mouse-up and key-up, and a drag-and-drop ends in neither.
|
||||
- `model_policies.js` mirrors `model_policies.py` for the frontend: which folder
|
||||
types a loader widget holds, which are never physically renamed, and which
|
||||
need a workflow-carried hash before Model Check recovers them.
|
||||
- `ui_dom.js` provides small DOM/JSON helpers; `material_inspector.js` owns the image
|
||||
workbench's metadata and node-parameter rendering.
|
||||
- `model_check.js` checks the open workflow's models against this computer (Model
|
||||
Check's verdicts, no DOM; the feature was called Model Doctor, and code and CSS still
|
||||
say "doctor") and puts a found file into its node; `ui_doctor.js` is the
|
||||
Model Check page and `ui_doctor_banner.js` the bar over the canvas when an opened workflow
|
||||
misses models (it also owns the check-on-open preference that Settings → Workflows and the
|
||||
bar's "Don't show again" set). `ui_node_assistant.js` owns the current-node panel (model actions, LoRA
|
||||
insertion, model cards and history), `ui_node_model_picker.js` owns native combo
|
||||
replacement, `ui_node_parameters.js` renders the panel's parameters section, and
|
||||
`node_parameter_sets.js` merges the node type's saved values (material files and recipe
|
||||
parameter sets) and works out what each would change on the node, keeping seeds,
|
||||
model files and missing choices; saved values (not a recipe's) are deleted there. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js`
|
||||
own the remaining explicit graph changes.
|
||||
- A model's download link lives in its user layer (`source_url` in `<model>.anomalous.json`, via
|
||||
`/anomalous/update_metadata`), the same field the model editor edits; for the open workflow's
|
||||
models it is also kept in `workflow.extra.anomalous_model_sources`, so shared workflows carry it.
|
||||
Workflow models are located here through `/anomalous/resolve_paths_to_previews`.
|
||||
- `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.
|
||||
|
||||
### UI Components (Dynamically Created):
|
||||
* **Sidebar (`anomalous-sidebar`)**: Renders the nested folder structure.
|
||||
* **Folder Manager (📁 Manage Folders)**: An interactive modal in `ui_sidebar.js` allowing drag-and-drop reordering and visibility toggling of folders. Drives backend I/O optimization.
|
||||
* **Grid (`anomalous-grid`)**: The main model display area. Emptied (`innerHTML = ''`) and repopulated on every folder click.
|
||||
* **Detail Panel (`anomalous-detail-panel`)**: Slides out when a specific model is clicked.
|
||||
* **Gallery Viewer (`ui_gallery.js`)**: A fullscreen modal for viewing images.
|
||||
* **Prompt Notes (`ui_notebooks.js`, legacy internal name)**: A specialized editor for composing prompts and drag-dropping LoRA/model nodes. Its shared user-facing surface is **Workspace / 创作工作台**, with **Prompt Notes / 提示词笔记** and **Workflow Recipes / 工作流配方** as its two sections. This presentation rename intentionally leaves notebook routes, storage, and internal property names stable for compatibility.
|
||||
* **Workflow Recipes (`ui_recipes.js`, `ui_recipe_detail.js`)**: A second section inside the shared Workspace modal, not a separate top-level navigation surface. Recipes save the complete serialized graph and add two summary layers: adapters for common model/LoRA/prompt/sampling semantics, plus bounded generic widget summaries for every node (including third-party nodes). Prompt extraction utilizes topological backward-tracing (from KSampler input links backwards to CLIPTextEncode nodes) to rigorously identify Positive and Negative tags without relying on fragile node-title regex matching. Save captures the serialized graph exactly once before the dialog; that snapshot, not a later live-canvas serialization, is the workflow sent to the API. Schema v3 introduced a canonical SHA-256 workflow fingerprint and explicit model-reference identity records; schema v4 adds optional recipe-owned model-preview snapshot descriptors. Schema v5 separates model identity from C-site Official origin metadata (model_name/model_url, which can be manually edited or auto-fetched via Civitai API by hash directly from the detail view), ensuring UI rendering prioritizes official names over obfuscated physical filenames. The card API stays light; the full graph and history are fetched only after edit, restore, or “View details.” The detail panel owns Overview, Parameters, and Versions tabs. Parameters are sorted topologically (using Kahn's algorithm on graph links) to match workflow execution order, and their display names dynamically resolve through `LiteGraph.registered_node_types` (and dummy node instantiation for widgets) to automatically support translation plugins. The save flow uses the product default for bounded model-preview snapshots without exposing a per-recipe checkbox; a future Workflow Recipe global setting may own that preference. The edit dialog resolves safe primitive values from the full workflow by node ID/widget index before updating the matching serialized slot; bounded summaries are never the edit source. Structural edits are explicitly loaded onto the canvas and saved back through the same update path. Every update archives the previous full recipe locally (bounded to 20 versions). Missing-node preflight also reads authoritative workflow node types. `recipe_parser.js` never mutates the live canvas.
|
||||
* **Recipe interaction extensions (`recipe_diff.js`, `recipe_actions.js`)**: Recipe cards filter only lightweight name/notes/tags metadata. Version comparison fetches one validated historical JSON and returns bounded semantic changes without instantiating or mutating either graph. Append clones serialized nodes into the live graph with collision-free IDs, transactional link remapping, placement, selection, and rollback. Card and detail actions use a shared busy/restore discipline so a network or canvas failure cannot leave a button permanently disabled or trigger duplicate mutations. Open/Append UI actions share the same exported handlers from `ui_recipe_detail.js`. All Quick Queue functionality was removed to enforce a single execution path through the canvas.
|
||||
* **Recipe detail presentation**: Copy actions use visible temporary success/failure feedback. Name, notes, and tags can be edited inline from the loaded detail view and are persisted through the existing full-recipe update contract, preserving the serialized workflow and history behavior. Workflow fingerprints and model SHA-256 values are advanced information shown only inside collapsed disclosure panels; card and detail surfaces use bounded glass-style cards, responsive grids, and restrained hover transitions. The Overview separates base model and each LoRA into one full-width composition block; sampling values remain in a compact key-value grid.
|
||||
* **Doctor Panel (`ui_doctor.js`)**: Diagnoses model health for individual nodes or the entire workflow. Includes "View Profile" functionality.
|
||||
## Cross-system invariants
|
||||
|
||||
### NON-NEGOTIABLE: Recipe Presentation & Model Preview Boundaries / 配方展示与模型预览边界
|
||||
* Recipe cover, current local model preview, and optional frozen model-preview snapshot are three different concepts. None is model identity evidence.
|
||||
* The authoritative `workflow` field contains only the serialized ComfyUI graph. Recipe covers, preview descriptors, and future snapshot asset IDs belong to additive recipe presentation data; never inject preview bytes or local preview URLs into the graph.
|
||||
* Recipe cards perform no per-model preview lookup. Current previews are demand-loaded only after the Models & reproducibility tab opens and must use a bounded exact-reference/category lookup. Detail opening must not recursively walk model folders.
|
||||
* Do not persist preview URLs as truth: they are current-machine cache locators and may change after rename or cover replacement. Display precedence is future frozen snapshot, then current local preview, then category placeholder.
|
||||
* Frozen preview snapshots are schema-v4 presentation data. The save flow uses them by default and does not expose a per-recipe checkbox; recipes with an explicitly stored `false` value remain compatible until a future Workflow Recipe global setting owns this preference. This default matters because an export can include only snapshots that were saved first. Assets live in a contained recipe-owned `.assets/<recipe-stem>/` directory, are immutable/content-addressed, and are served only through `GET /anomalous/recipe_asset`. Snapshot generation accepts static local preview files only, writes WebP thumbnails no larger than 320 px or 96 KiB each, is capped at 12 images and 1.25 MiB per save/update, and never copies videos or original full-resolution media. Deleting a recipe removes only its contained assets after the recipe file is deleted; retained history may share an asset ID. Export still asks whether to include these bounded presentation assets in the package.
|
||||
* Preview hashes, filenames, paths, images, and visual similarity MUST NEVER create a Verified identity badge or participate in Model Doctor matching. Workflow fingerprints also exclude covers and preview presentation.
|
||||
* Recipe model cards may display the basename and current local preview by default, with full saved paths and hashes behind Advanced information. Overview and compact recipe summaries must use the basename only; a filesystem path is never a model display name. Exact local references are used only for current-machine preview lookup and temporary navigation into the browser detail panel. Imported or unresolved references remain visually inactive until the user explicitly requests matching through the existing hash/size/category resolver; filename or path alone can never activate a match. A successful match activates only the current detail view and does not rewrite the authoritative workflow path.
|
||||
* Navigating from a resolved recipe model reference to the model browser detail panel must preserve the main browser modal. The recipe workspace child overlay is hidden after the detail panel is shown; it must not call the browser-wide `close()` lifecycle, because that lifecycle also hides and releases the target detail panel. The navigation stores a lightweight recipe return token (recipe payload, active tab, and scroll position); the model detail Back action consumes that token, hides/cleans the model panel, and restores the recipe detail view rather than falling back to the model grid.
|
||||
* Local model-preview media may include image, MP4, or WebM files and uses hover playback for video. Recipe save/export never embeds original video media: a bound output video may contribute only a bounded static first-frame thumbnail, while package assets remain contained WebP snapshots.
|
||||
* Detail readability is not card-summary readability: a compact card may use bounded summaries, but the detail panel may resolve the corresponding safe widget value from the already-loaded contained `workflow` by node ID and widget index. Sensitive/button widgets remain excluded. Long values and positive/negative prompts must use a visible collapse/expand control plus a copy action; never silently truncate a detail value. Overview may show normalized positive/negative prompt summaries, while Parameters preserves native node names and renders each saved `CLIPTextEncode` text widget separately. If the bounded summary omits an old native CLIP node, Parameters must recover it from the authoritative serialized workflow's `widgets_values` without changing the saved graph, then keep native CLIP rows together in the presentation order.
|
||||
These rules are intentionally summarized here and specified in the linked topic
|
||||
documents.
|
||||
|
||||
### NON-NEGOTIABLE: Recipe Action Boundaries / 配方动作边界
|
||||
* `Open in Canvas` replaces the live graph only after an explicit dirty-canvas confirmation and never queues automatically. `Append to Canvas` clones saved nodes, remaps IDs/links, validates the complete insertion, and rolls back all inserted nodes on failure. Detail actions use one busy/settle path so a successful canvas action resolves the detail controller and a failed action restores the button state.
|
||||
* Append must not call `loadGraphData` on the live graph. It must not mutate the saved recipe, existing nodes, existing links, or external model files. Unsupported/missing node types produce a visible error instead of a destructive fallback.
|
||||
* Append accepts both LiteGraph tuple links and object-shaped link records, treats groups as first-class inserted items, rejects subgraph definitions until a dedicated ID-remapping path exists, and removes every item created by the action when any insertion step fails.
|
||||
* `recipe_diff.js` compares bounded semantic records only. It must not expose newly-unfiltered sensitive widget values, render a full graph diff, instantiate saved nodes for display, or mutate either recipe.
|
||||
1. **Identity is provenance, not naming.** Model Check 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.
|
||||
|
||||
### Graph Splicing and Picker Context (`graph_splice.js`, `model_picker.js`)
|
||||
Manual LoRA insertion is an explicit canvas mutation and is isolated from Model Doctor identity recovery. The graph helper analyzes ports by their declared `MODEL` and `CLIP` types, never by fixed slot indexes or display names. It supports inserting a compatible loader before a node with connected MODEL/CLIP inputs, or after a node with MODEL/CLIP outputs. Downstream fan-out is rejected as ambiguous until the user can choose a concrete branch.
|
||||
## Data and compatibility boundaries
|
||||
|
||||
Graph edits are transactional: validate the complete topology before mutation, wrap the operation in `graph.beforeChange()` / `graph.afterChange()`, and restore every original connection if node creation or any link operation fails. A successful insertion must be one undoable graph change and must dirty the canvas. The helper must not move, delete, or rewrite existing nodes or widgets, and model choices supplied by the UI must remain constrained to ComfyUI's native combo values for the inserted node.
|
||||
- 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.
|
||||
|
||||
The Node Assistant owns the corresponding picker UI. Replacement actions live in the selected-node toolbar, while insert-before/insert-after actions are enabled only when graph analysis reports a safe topology. The picker derives its complete candidate set from the target widget's native combo values, then provides client-side folder browsing, full-path search, and sorting. It must not broaden the list with models from another category or require metadata scanning merely to select a filename.
|
||||
## Change and snapshot protocol
|
||||
|
||||
`model_picker.js` owns pure picker-type inference, base-model family normalization, and bounded upstream MODEL/CLIP traversal used to discover the nearest non-LoRA main model. Picker presentation metadata is resolved in one batch through `/anomalous/resolve_paths_to_previews`. The request supplies the native folder-type boundary inferred from the target widget, and the response may include preview URLs, model category, and cached sidecar metadata for each requested path. Picker cards expose category and `baseModel` badges using text-only DOM assignment. LoRA compatibility filtering uses the connected main model's `baseModel` metadata, falling back to the current LoRA's metadata only when the main model cannot be identified. This is a user-visible browsing filter, not Model Doctor identity evidence; missing metadata must remain accessible through an explicit unfiltered option.
|
||||
The per-commit requirements (which document to update, the structure check,
|
||||
verification, snapshots and pushing) live in section 0 of [AGENTS.md](AGENTS.md)
|
||||
and are not repeated here. Update this entry point only when the system map,
|
||||
cross-system invariants, or reading map change; everything else goes in the
|
||||
narrowest topic document.
|
||||
|
||||
### JavaScript Rules of Engagement:
|
||||
1. **Strict Localization (双语)**:
|
||||
- Never use hardcoded UI strings.
|
||||
- Always use the ternary operator bounded to the global state: `window.anomalous_browser_lang === 'zh' ? '中文' : 'English'`.
|
||||
- Never use variables like `currentLang === 'zh'` for DOM rendering, because they fail when closure scopes diverge from the global state.
|
||||
2. **Avoid Global Scope Pollution**: Scope all IDs and classes with `anomalous-`.
|
||||
3. **Z-Index Tiers & Overlap Prevention (层叠规范)**:
|
||||
- Base UI: `10000`
|
||||
- Overlay Modals: `10001` to `999999`
|
||||
- *Critical Rule*: Never arbitrarily assign z-indexes. A child modal MUST have a strictly higher z-index to prevent the parent from consuming clicks.
|
||||
4. **DOM ID Conflicts with CSS**: When assigning an `id` to an element dynamically, always `grep_search` `styles.css` first.
|
||||
5. **Untrusted Text and Rich HTML**: Filenames, folder names, notebook names, workflow values, and metadata are untrusted. Use `textContent` or `escapeHtml()` for normal text. Only Civitai description/notes fields may retain formatting, and they must be inserted with `setSafeRichHtml()` from `safe_dom.js`; direct metadata assignment to `innerHTML` is forbidden.
|
||||
|
||||
## 5. Hash Resolver Subsystem & Scanning Engine (`hash_resolver.js` & `scraper.py`)
|
||||
A highly complex subsystem responsible for automatically resolving missing/broken model references in workflows, and aggressively scanning models to build local caches.
|
||||
|
||||
### NON-NEGOTIABLE: Model Doctor Identity Boundary / 模型医生身份判定死规矩
|
||||
* Model Doctor exists to recover the same physical model referenced by provenance data embedded by this plugin in an exported workflow or image. Local path differences and local renames are the problem being solved; therefore a path, filename, display name, custom name, source filename, or fuzzy name similarity MUST NEVER be used as evidence that two models are identical.
|
||||
* Automatic identity evidence is limited to plugin-carried cryptographic hashes, exact physical byte size as a controlled fallback/disambiguator, and the model category required by the target widget. Byte size alone is acceptable only when it produces exactly one candidate inside the required category and no target hash contradicts it; a supplied hash mismatch never falls back to a filename or size-only guess.
|
||||
* Paths and filenames may be used only after model identity has already been established: to return the resolved local dropdown value and to verify that value against ComfyUI's native combo choices. They must not participate in candidate selection, ranking, tie-breaking, or ambiguity resolution.
|
||||
* Normalizing slash direction for a value that already has an exact equivalent in the widget's native ComfyUI options is representation normalization, not model discovery, and does not weaken this identity boundary.
|
||||
* If a legacy/corrupt hash cannot be recovered uniquely from allowed evidence (for example several in-category files have the same byte size), Model Doctor MUST report ambiguity or remain unresolved. It must never guess from the old filename/path. Manual replacement is the explicit fallback.
|
||||
* This boundary is part of the plugin's native product contract. Do not weaken, reinterpret, or bypass it without explicit user authorization.
|
||||
|
||||
### The Global Hash Cache (`window.anomalous_hash_cache`)
|
||||
* This is the absolute core of the resolution engine. It maps physical filenames to their exact SHA256 hashes.
|
||||
* It is fetched on startup by the optional resolver via `fetch('/anomalous/all_hashes')`. If the current ComfyUI frontend does not expose a compatible graph serialization API, the resolver disables itself with a warning; this must never prevent the main browser extension and its visible entry point from loading.
|
||||
* Whenever a scan finishes (Scan Wizard or Deep Hash Scan), `window.anomalous_reload_hashes()` MUST be called to synchronize the frontend dictionary with the newly generated `.info` files on the disk.
|
||||
* The cache exposes relative-path and basename aliases only when they are unambiguous. If two local models share a key but have different hash/size values, that alias is omitted instead of silently choosing one. Frontend lookups must prefer the full widget value before falling back to a basename.
|
||||
|
||||
### Model Provenance Binding (模型溯源绑定 - `anomalous_inject_hash`)
|
||||
* Controls whether hashes are invisibly injected into the `extra_pnginfo` and workflow JSON when a user saves a workflow or generates an image.
|
||||
* **Logic Flow**: The active graph constructor's `prototype.serialize` is hooked when available (falling back to the legacy global `LGraph` only when present). If enabled, it intercepts the serialization, looks up every model widget's filename in `window.anomalous_hash_cache`, and explicitly writes `extraObj.anomalous_hashes[node_id_filename] = {hash, size}`. A missing graph API disables only this optional hook.
|
||||
* Exact-path checks are limited to pre-flight verification that an already-resolved local reference exists; they are outside Model Doctor provenance recovery and must never be used as its fallback. For provenance recovery, the backend constrains the search to the model category inferred from the node/widget and intersects the saved hash with the saved byte size. If no file in that category owns a legacy/stale hash, only a unique in-category byte-size match may recover the model as `stale_hash`. Equal-sized candidates remain unresolved, and a real hash/size conflict is rejected instead of choosing arbitrarily.
|
||||
* Civitai `.info` files may describe several physical files (for example a diffusion model, text encoder, and VAE). `get_metadata()` must select the matching `files[]` entry by exact physical byte size and may use an unmatched entry only when it is the sole hash candidate. It must never take the first SHA256 or use a filename to choose between entries.
|
||||
|
||||
### The Triple-Fallback Scanning Engine (`api/scanner.py` & `scraper.py`)
|
||||
When "Deep Hash Scan" is triggered, it runs in a background thread to prevent UI lockup. It uses a triple fallback to identify models:
|
||||
1. **Fallback 1 (Header Hash Match)**: Extracts `modelspec.hash.sha256` or Blake3/AutoV2 directly from the `.safetensors` header (O(1) speed). Hits Civitai API to fetch official metadata.
|
||||
2. **Fallback 2 (Full File SHA256)**: If the header has no hash, it brutally calculates the full file SHA256 (Slow) and hits Civitai.
|
||||
3. **Fallback 3 (Offline Inference)**: If Civitai returns 404, it reads the Tensor Fingerprints (keys like `cond_stage_model`) from the header to guess the Base Model (SDXL, SD 1.5, Flux, SD3) and builds a local `.info` file.
|
||||
* **Offline hash injection**: After a fallback succeeds, the engine injects the discovered hash into the generated JSON. Consumers must still associate that entry with the current physical file using size/name matching; array position is not an identity guarantee.
|
||||
* Physical rename conflicts are non-destructive. When the target filename already exists, both files must be hashed; deletion is permitted only when their complete SHA256 values match. Different files with the same generated display name must both be preserved.
|
||||
|
||||
### Resolution Execution (`window.anomalous_resolve_all_missing_nodes`)
|
||||
Scans all nodes in `app.graph._nodes` that are colored red.
|
||||
* Extracts the saved hash from the graph's `anomalous_hashes`.
|
||||
* Sends hash, byte size, and the inferred model category to `/anomalous/resolve_hash`. The endpoint accepts a result only when the identity signals agree, or when a unique in-category size resolves a request that has no usable hash; a contradictory supplied hash remains unresolved.
|
||||
* If a match is found, the frontend refreshes native ComfyUI combo definitions and requires the returned path to exist in that widget's native choices. Cross-category or otherwise invalid paths are rejected; the resolver must never append a foreign path to `widget.options.values` merely to report success.
|
||||
* After native validation succeeds, it mutates the dropdown `value`, removes the red color, and flags it as `anomalous_auto_resolved`.
|
||||
|
||||
## 6. Known Gotchas & Critical Context (新接手必读)
|
||||
* **Metadata Override Danger**: If you write to a `.civitai.info` file, ALWAYS read it first and update the dictionary. Never just dump `{custom_name: 'foo'}`.
|
||||
* **Search Context Trap**: If the user searches using the sidebar while a detail panel is open, you MUST explicitly hide the detail panel and show the grid.
|
||||
* **Media Leakage**: Destroying a DOM element that contains a playing `<video>` or `<audio>` does **not** stop the audio. You must `.pause()` it before doing `innerHTML = ''`.
|
||||
* **Video Cover Compatibility & Interaction**: Any frontend component displaying model preview covers MUST check if the file is `.mp4`/`.webm`. The model-card setting owns the grid behavior: `always` may autoplay only while the card is near/in the viewport; `hover` loads/plays on pointer hover and pauses on leave. Both modes use muted/loop/playsinline and release their source when the browser closes. Single main covers may keep their established autoplay behavior. **CRITICAL GOTCHA**: Do NOT use `new URL(url).pathname` to check extensions! Our backend serves images via query parameters (e.g. `?filename=cover.mp4`), so `pathname` strips the filename. Always test the full URL using regex like `/\.(mp4|webm)(?:$|\?|&|#)/i`.
|
||||
* **Variable Naming Collisions**: When moving UI elements, search for ALL references to the old `const` declaration. A single duplicate `const` in the same scope kills the entire module at parse time.
|
||||
* **Data Scaling & Payload Limits**: When passing arrays/lists (e.g. hundreds of selected files), NEVER append them to URL query parameters (`GET` requests), or you will trigger `414 URI Too Long`. Use `POST` with a `JSON Body`. Similarly, NEVER pass massive arrays directly to `subprocess.run` via CLI arguments, as it will crash silently due to OS character limits (8191 on Windows). Always dump massive data to an intermediate `.json` file for the subprocess to read.
|
||||
|
||||
> For a complete list of past mistakes and detailed post-mortems, refer to `.agents/logs/ai_lessons.md`. Engineering snapshots and handoff notes belong in `.agents/logs/ai_changelog.md`.
|
||||
|
||||
## 7. Mandatory Change Snapshot Protocol (强制变更快照流程)
|
||||
|
||||
Every product-code change in this plugin must finish as one coherent local Git snapshot:
|
||||
|
||||
1. Run checks proportional to the touched behavior before committing.
|
||||
2. Update `.agents/logs/ai_changelog.md` in the same change with the implementation summary, architectural decision, checks run, and handoff notes.
|
||||
3. Update this `ARCHITECTURE.md` in the same change so directory ownership, data flow, API contracts, naming boundaries, and non-negotiable rules remain accurate. If the code does not alter an architectural boundary, record that the existing boundary is intentionally unchanged rather than inventing a new abstraction.
|
||||
4. Create a local Git commit after verification. Keep unrelated work out of the snapshot and do not push unless the user explicitly requests it.
|
||||
5. Leave a clean worktree for the next agent, or document every intentional uncommitted file in the handoff.
|
||||
|
||||
`CHANGELOG.md` is public user-facing release communication. Update it only for intentional user-visible release notes; never use it for internal design decisions, implementation details, test output, or agent handoff. Planning-only documentation may be committed separately, but proposals must be labeled clearly and must not be described as implemented runtime behavior.
|
||||
|
||||
## 66. Recipe Canvas Actions Use Append as the Safe Composition Path
|
||||
|
||||
Recipe cards and detail views intentionally expose only “Append to Canvas”. The removed “Open to canvas” action was ambiguous across ComfyUI versions: the legacy API replaced the current graph, while the current workflow-tab API opens a new temporary tab unless an internal active-workflow object is supplied. Keeping only append avoids both meanings and preserves the user's current canvas.
|
||||
|
||||
The separate structural-edit action may still load a recipe into a new canvas for editing; it is not a recipe composition action and remains explicitly confirmed. Successful append actions continue to hide the notebook body, recipe view, recipe modal panel, and outer modal before invoking the existing close/media cleanup.
|
||||
|
||||
## 67. Recipe Save Does Not Ask for Presentation Pins or Snapshot Policy
|
||||
|
||||
The save dialog records recipe identity, notes, tags, and cover/source image. It does not render the expandable “Choose key parameters” section or a model-preview snapshot policy checkbox: the complete serialized workflow remains the source of truth, and these implementation choices should not burden users who only want to save/share a recipe. The current bounded snapshot default is internal and can later move behind a Workflow Recipe global setting. Existing `params.pinned` data is preserved when editing an older recipe so removing the control is not a destructive metadata migration; new saves use an empty pin list.
|
||||
|
||||
## 68. Parameter Notebook Switches Must Identify the Right-Hand State
|
||||
|
||||
The Parameters tab is a two-pane history browser, so highlighting only the left notebook list is insufficient feedback. The right pane must show the active notebook name and timestamp in a prominent selection banner and animate the panel on a genuine notebook switch. Parameter-gallery requests carry a selection token and must discard stale responses after the user switches notebooks.
|
||||
|
||||
Saved parameter notes are individually removable from the notebook sidebar. The row uses sibling selection and delete buttons rather than nested interactive controls, asks for explicit confirmation, and posts only the validated notebook filename to `/anomalous/delete_parameter`. Deleting the active note clears its selection, invalidates in-flight parameter-gallery requests, reloads the notebook list, and selects the newest remaining note. The current recipe parameter baseline is not a stored notebook row and therefore cannot be deleted through this control.
|
||||
|
||||
## 69. Docked Notebook Controls Must Shrink as a Unit
|
||||
|
||||
The notebook create row contains a text input and a confirmation button. In the 660px docked browser, the notebook sidebar is reduced to 80px, so the input must have `min-width: 0` and the confirmation button must remain a non-shrinking fixed-size item. Otherwise the input's placeholder intrinsic width pushes the check button outside the clickable sidebar.
|
||||
|
||||
The output gallery is a user-refreshed view of ComfyUI's output directory. Opening the gallery performs one page-one scan, and the toolbar's Refresh button explicitly repeats that scan on demand. There is no background polling; the grid preserves scroll position when manually refreshed.
|
||||
|
||||
## 70. Keep the main browser entry module parseable
|
||||
|
||||
The floating trigger is created by `web/main.js`, but ComfyUI loads every JavaScript file in the custom node's `WEB_DIRECTORY` as an ES module. A syntax or duplicate top-level declaration in any imported module prevents `main.js` from registering and leaves no visible plugin icon, even when the backend routes and the optional hash resolver are healthy.
|
||||
|
||||
When a trigger disappears, verify the browser extension registry and load the main entry in isolation before changing CSS or z-index values. In particular, do not duplicate helper declarations while merging recipe UI changes; shared helpers in `ui_recipes.js` must have one definition per module. `node --check` is required for the affected module, and a real ComfyUI runtime check must confirm that `#anomalous-trigger-btn` is created.
|
||||
|
||||
## 71. Recipe and Node Assistant Integration Must Fail Loudly and Preserve Host State
|
||||
|
||||
The Node Assistant consumes parameter notebooks by node type, while the Recipe detail remains the authoritative source for full-workflow skeleton matching. The assistant's preset endpoint may cache notebook discovery briefly, but save/delete operations invalidate that cache and an explicit assistant refresh requests a fresh read.
|
||||
|
||||
Applying a node preset is a transactional live-widget operation: volatile seed slots are ignored, values are cloned safely, widget callbacks and ComfyUI's four-argument `onWidgetChanged(index, value, previousValue, liveWidget)` hook are invoked only when callable, and callback failures roll back the node before the UI reports failure. A successful write marks both graph and canvas dirty and emits `graphChanged` so recipe and assistant surfaces can refresh.
|
||||
|
||||
Prompt roles are derived only from an explicit allowlist of ComfyUI prompt nodes, consumers, and conditioning pass-through nodes. Link storage may be an array, object, or `Map`, and every linked role input is inspected. Unknown third-party nodes are opaque boundaries: neither their title nor their type name may turn an unresolved text into a positive or negative prompt. Saved role metadata is joined to assistant records using string-safe node IDs; legacy recipes use exact, unambiguous full-value recovery and otherwise remain unknown.
|
||||
|
||||
The assistant must not call removed doctor-panel methods from canvas selection hooks. Existing host selection callbacks are preserved, and the assistant hook only updates its own panel.
|
||||
|
||||
## 72. Legacy Prompt Roles Are Recovered from Full Prompt Values
|
||||
|
||||
Older recipes may contain correct `params.promptPositive` and `params.promptNegative` arrays but no per-node `role` field. When the Node Assistant prepares a node-type preset response, the backend compares the node's complete saved text widget value against those arrays and injects a role only when the match is unambiguous. An identical value present in both arrays remains unclassified rather than being guessed; explicit metadata roles always take precedence.
|
||||
|
||||
## 73. Prompt Roles Are Conservative and User-Correctable
|
||||
|
||||
The first supported automatic contract is intentionally narrow: ComfyUI's native `CLIPTextEncode` can receive a role only through allowlisted official sampler/Guider inputs and allowlisted conditioning pass-through nodes. An unresolved native prompt is `unknown`; a third-party text candidate is also `unknown` unless the user labels it. The detail panel states this limitation directly and never uses node titles, prompt content, or a default-positive rule as semantic evidence.
|
||||
|
||||
Manual labels are recipe-skeleton metadata stored in `params.promptRoleOverrides`, keyed by string-safe node ID and guarded by the saved node type. Supported values are `positive`, `negative`, `both`, `ignored`, and `unknown`; removing an override restores the saved automatic result. Recipe updates retain an override only while node ID and type still match. The Parameters view and Node Assistant must consume the recipe override before automatic or legacy metadata, so all surfaces show the same role. Future third-party adapters may expand the allowlist, but unknown nodes must remain conservative until explicitly supported.
|
||||
|
||||
## 74. MIT Copyright Permission and Project Branding Are Separate
|
||||
|
||||
`LICENSE` remains the unmodified standard MIT license and is the copyright license for repository code, documentation, stylesheets, and ordinary UI resources unless a file expressly says otherwise. Do not append project-specific restrictions to that license or describe ordinary UI implementation as proprietary while simultaneously licensing the repository under MIT.
|
||||
|
||||
`TRADEMARKS.md` owns the separate source-identification boundary for the name **Anomalous Model Browser**, stylized presentations of that name, and any graphic explicitly designated as the official logo. It permits truthful references, links, compatibility statements, unmodified redistribution, and clear “based on” attribution. Public modified distributions use a distinct primary name/logo and must not imply official status or endorsement. README summarizes and links to both documents; legal-policy changes must be intentional and must not be hidden in engineering changelogs.
|
||||
|
||||
## 75. Beta Workflow Features Carry Localized Data-Safety Guidance
|
||||
|
||||
Workflow Recipes and the Node Assistant's recipe-powered parameter presets are explicitly presented as beta features until a stable release decision removes that status. The Node Assistant's Actions tab (visual model replacement and LoRA insertion) is mature and outside that beta boundary. The Recipe list renders its compact notice at library level, while the assistant notice belongs inside the hidden-by-default Parameter Presets container and appears only when that tab is active; no warning sentence is hardcoded in a UI module. The Settings Help content and bilingual README repeat the operational boundary and identify `workflows/anomalous_recipes` plus `workflows/anomalous_parameters` under the active ComfyUI user directory as the data to back up.
|
||||
|
||||
Documentation distinguishes the two application scopes: Node Assistant presets update the currently selected same-type node and ignore volatile values, while a Parameter Notebook's “Apply to Current Workflow” verifies the complete recipe skeleton before updating safe widgets. Model-preview snapshots are presentation assets, never model-file backups. README and Help must remain bilingual and describe the same behavior whenever these workflows change.
|
||||
|
||||
`locales.js` is the authoritative source for every user-visible runtime string in this feature. Chinese and English dictionaries must have identical key sets, no duplicate keys, and all statically referenced `t('key')` calls must resolve in both dictionaries. Language parity and static-key coverage are release checks for changes to Recipe, Assistant, Settings, or Help surfaces.
|
||||
|
||||
Persistent beta notices carry `data-anomalous-i18n-key` metadata. The sidebar language switch refreshes those live nodes in place and rebuilds the Node Assistant around the current selection, so changing language does not require closing either surface and cannot leave its warning, placeholder, or selected-node content in the previous language. Beta notices must be scoped to the exact experimental interaction rather than a stable parent surface. Any future persistent localized notice should use the same contract.
|
||||
|
||||
## 76. Recipe-to-Model Navigation Is an Exclusive, Recoverable Transition
|
||||
|
||||
Opening a local model from Recipe Overview temporarily replaces every main content surface: grid, Gallery, Model Doctor, Node Assistant, parameter panel, and Workspace are hidden before the model detail becomes visible. The recipe payload, active tab, scroll position, and original `workspaceReturnState` remain available across the transition, while `recipeModelReturn` exclusively owns reconstruction of the recipe detail; model detail and Node Assistant must never be visible together.
|
||||
|
||||
There are two valid ways to end this transition. The model detail Back action (or reopening Workspace and choosing Workflow Recipes) consumes `recipeModelReturn` and reconstructs the exact recipe detail. Navigating directly to another main panel or closing Workspace abandons it: stale payload/scroll state is cleared, detail media and DOM are released, and the recipe list plus action bar are restored. `showRecipes()` also normalizes those list surfaces defensively whenever no detail is active, so a lost callback cannot produce a blank Recipe body. Opening Workspace while a recipe-model return is pending must not overwrite the earlier `workspaceReturnState`, otherwise closing the reconstructed recipe could restore an empty model-detail panel.
|
||||
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,53 +1,339 @@
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
## 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.
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **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.58.2 Beta (Small fixes: Esc closes, Show in folder, insert a picture as a node) — 2026-10-13
|
||||
|
||||
### 💬 From a Bilibili comment (来自 B 站评论)
|
||||
- **Esc closes the browser (Esc 关闭浏览器)**: Esc closes a dialog or menu first; on a page one level down (a model's details, one combo, one workflow) it goes back to the list, as the page's back button does; on any other page, Workflows and Combos included, it closes the browser. The shortcut and the Extensions menu entry now close the browser when it is open. Esc in a text box still belongs to the text box.
|
||||
- **Shift + B opens the browser (Shift + B 打开浏览器)**: the default shortcut is now **Shift + B** (again to close it). Ctrl + Shift + M is taken by Chrome and Edge (switch profile) before ComfyUI sees it, and B alone already opens comfyui-browser's window. A shortcut you changed yourself stays as it is; change it in ComfyUI Settings → Keybinding.
|
||||
- **Dragging a gallery picture hides the browser (拖图时浏览器让开)**: while you drag a picture, the browser fades out as it does for other cards, so the canvas and the node under it take the drop.
|
||||
- **Show in folder (打开所在文件夹)**: a model's details have **Show in folder** next to **Copy path**: Explorer (or Finder) opens in front of the browser with the file selected. Only on the computer ComfyUI runs on.
|
||||
- **Insert a picture as a node (图片作为节点插入)**: right-click a gallery picture → **Insert as a Load Image node** gives you a Load Image node showing that picture: the browser closes and the node follows the pointer until a click puts it down (Esc cancels), like a combo's **Put on canvas**. The picture is copied into ComfyUI's input folder, as when you drop a file on the node (the same picture is kept once).
|
||||
|
||||
### ⌨️ Arrow keys in picture details (查看参数里方向键切图)
|
||||
- In a picture's details, **← / →** (and **A / D**) switch pictures again after you ticked a node's checkbox under **All parameters**; the ticked checkbox kept the keyboard and the keys did nothing until you clicked elsewhere. Typing in a text box still keeps the arrows for the text.
|
||||
|
||||
### 🌐 English interface (英文界面)
|
||||
- Picture details no longer show Chinese in the English interface: the step count ("20 steps"), **Show model** on the Models tab, the copied-JSON note and the empty-tab notes.
|
||||
- Counts read right for one: "Changed 1 thing on the canvas", "1 model", "1 file" instead of "1 things", "1 models", "1 files".
|
||||
- The voice guides name the node as English ComfyUI shows it: **Character Speech (GPT-SoVITS)**.
|
||||
|
||||
## v1.58.1 Beta (Security: only this computer can change things) — 2026-10-12
|
||||
|
||||
### 🔒 Other devices can look, not change (别的设备只能看,不能改)
|
||||
- When ComfyUI is started with `--listen`, other devices on your network can open it. Until now they could also use most of Anomalous Model Browser's actions: delete or rename models, save and delete workflows, combos and prompts, start downloads and scans, export a backup of all your data, translate with your translation key, change settings, switch the plugin's version. Now every change answers only the computer ComfyUI runs on; other devices can still browse models, pictures and workflows.
|
||||
- A window opened from another device shows **View only** next to the page title; pressing a button that changes something there says why it did nothing.
|
||||
- A web page that reaches ComfyUI through another name for this computer, or a proxy, counts as another device too.
|
||||
|
||||
## v1.58.0 Beta (New Layout, Keep & Reuse, Prompt Studio, Model Check, Voices) — 2026-10-09
|
||||
|
||||
### 🧭 New layout (新布局)
|
||||
- **Icon rail (左侧图标栏)**: every page now has one place on the left: Home, Activity, Models, Gallery, Workflows, Combos, Prompts, then Voices and Audio for the audio side, then scan, Model Check and Current node, with settings at the bottom. It replaces the tabs at the top, the image/audio switch and the tool bar under the folder list.
|
||||
- **Folder list opens and closes per page (列表按页面收放)**: click the current page's icon again, or the button at the top left, to hide or show its list; each page remembers your choice. Pages without a list (Home, Gallery) give the space to the content. In a narrow or docked window the list starts closed and opens as a drawer against the rail, over a dimmed page; picking a folder or character, pressing the dimmed page or Esc closes it.
|
||||
- **Home page (主页)**: "What would you like to do?" with a card per task (find a model, fix missing models, give a character a voice, look through outputs, keep workflows, use a combo, put prompts together) and first steps (scan model folders, tour the interface, what's new). The first time you open the browser it starts here; after that it reopens the page you used last.
|
||||
- The header shows the current page's name; the workflow and combo workspaces keep the rail visible, so you can switch pages without closing them first.
|
||||
- **No more toolbox (工具箱拆掉了)**: every tool now opens from the page it belongs to. Workflow share codes (import / export) are the ⇅ button at the top right of **Workflows** (where a disabled import button used to sit); **Prompt Studio** opens from **Prompts** on the rail (Prompt Notes became **Combos**, a page of their own); **Model Sources** is at the end of the type chips on **Models**, and Model Check has **Where to download** for the open workflow's models; translation is on the prompt boxes in **Current node**. The rail keeps scan, Model Check and Current node.
|
||||
- **No more Material Library page (素材页拆掉了)**: it held four unrelated things, so each went where it is used. **Prompts** on the rail opens Prompt Studio beside the canvas, where your saved prompts are (and can be renamed, deleted and dragged onto the canvas); the browser comes back when you close the studio, and the second keyboard shortcut (Ctrl+Shift+L) now opens the studio too. Saved node values are in **Current node** when you select a node of their kind, which can now delete them. Recent results are the gallery's ☆, combos have their own page, and whole workflows are recipes. The recipe page's buttons that copied parameter sets into the library are gone (Current node lists recipe sets already), and saving nodes from an image's details says where they went. Nothing is deleted: your saved prompts and values stay in the same files.
|
||||
- **A settings page (设置页)**: the gear opens a page instead of a small pop-up with a second dialog inside: Look (view mode, atmosphere, interface scale as a slider, theme, language), Model cards and memory (thumbnails or originals, video covers, the thumbnail cache with its size and an Empty button), Model folders, Workflows (model fingerprints), Opening and window (where the browser opens from, reset layout) and Help and feedback. Each setting says what it does and what it costs; Back returns to the page you came from.
|
||||
- **One look everywhere (配色统一)**: pages made at different times used their own greys (bluish slate here, neutral grey there) and their own accent (sky blue, violet, indigo). They now all take the Home page's colours: neutral surfaces, the same text greys and one accent for links and selection, and they follow the Abyssal Scarlet theme too (the Material Library, Prompt Studio, voices and several dialogs used to stay blue-grey in it). The workflow share-code dialogs lost their blue and orange buttons for the usual ones. Status colours and prompt-role colours are unchanged.
|
||||
- **Easy feedback (反馈更方便)**: Home ends with "Found a problem, or have an idea?" and Settings has the same under Help and feedback. **Report a problem** and **Suggest something** open a small window with one text box (a sentence or two, any language); **Submit on GitHub** then opens a new issue with it written in, titled from its first words, in the browser's language, so only Submit is left to press. The environment (plugin and ComfyUI versions, Python and PyTorch, GPU and memory, OS and browser, the page you were on; never a file path or ComfyUI's command line) is attached folded at the end when ticked, and can be looked at first; **Copy diagnostics** copies it for anywhere else. The repository's issue forms ask just what went wrong, with optional screenshots.
|
||||
- **No more manual pop-up (使用手册弹窗去掉了)**: the long help text, which had fallen behind the window (it still called recipes and materials "in testing"), is gone; each page now explains itself, Home lists what you can do, and **!** shows what changed. Settings → Help and feedback has **Interface tour**, **What's new**, **Full guide** (the README on GitHub, at its Chinese half in Chinese) and the feedback buttons. The README's backup note no longer calls these features a test.
|
||||
- **Model folders, readable (模型文件夹设置更好懂)**: folder management is a view of the settings page instead of a pop-up. Grouping is two plain choices, **By model type** or **By folders on disk** (it was "Category mode / Physical mode"); the types shown on the Models page come first, each with its usual name (Checkpoint, Diffusion…) and folder name, a switch and ↑↓ to order them; the ones that are off follow, and the many folders ComfyUI registers that few people use (configs, datasets…) fold away. Each change applies at once; there is no Save and reload button any more.
|
||||
- **Up-to-date guides (说明跟上新界面)**: the **!** "What's new" guide and the README now describe the rail, Home, keeping results, Prompt Studio, Model Check and character voices instead of the old tabs and bottom bar.
|
||||
- **The tour follows how you work (导览按使用顺序讲)**: the interface tour now goes the way the pages are used: the rail as one line (pick models, keep a good result, use it again), the Gallery's ☆ and where each kind of keep goes, Workflows (with share codes), Combos, Prompt Studio, Current node, then scanning, Model Check, the **!** guide and Settings. Steps that still described translating on Current node and the old scan wizard are rewritten, and the tour's text follows the interface language like the rest of the browser.
|
||||
|
||||
### ⭐ Keep and use again (收藏与复用)
|
||||
- **Keep a result from the gallery (图库里点 ☆ 收藏)**: every image in the gallery has a ☆. It asks what to keep, and says where each goes: **The whole workflow** (a Workflow Recipe on the Workflows page, with the picture as its cover), **Its combo** (the main model, LoRAs and positive prompt, on the Combos page, ready to put on the canvas; models this computer does not have are left out and the message says how many) or **Just the prompts** (positive and negative, in your saved prompts). No name is asked: it is named after the prompt. Kept images show ★; a row already kept shows ✓ and opens it, and its **Remove** (取消收藏) moves what was kept to the Recycle Bin after asking, so the star goes back to ☆. The image workbench's **Keep** opens the same menu (its name-and-tags form is gone). The Material Library's Recent shelf, which did the same for whole workflows only, is gone: the gallery lists the same images. Deleting an image from the gallery asks in the usual dialog instead of over the picture.
|
||||
- **Whole workflows live on the Workflows page (完整工作流都在“工作流”页)**: a result kept whole (now with the gallery's ☆, or from the image workbench) is a Workflow Recipe with that picture as its cover (it was a material), so versions, parameter sets and Model Check work on it. The Material Library keeps the pieces (nodes, parameters, prompts) and lost its Workflow category. Whole workflows kept as materials before show on the Workflows page as "N more in the library — Move them here"; moving saves each as a recipe and leaves the material file where it is, just no longer listed. Recipes that are not whole workflows are called Modules everywhere (the filter said "Sub-graph").
|
||||
- **Prompt Notes are now Combos, a page of their own (提示词笔记改名“搭配”,有了自己的页面)**: a note was always a main model, a few LoRAs and a prompt kept together, so it is called a combo and sits on the rail as **Combos**, with a card on Home. The page shows every combo as a card with its model's cover, its LoRAs and the start of its prompt, a search box and **New combo**; a card opens the combo to edit, and **‹ All combos** goes back. The old notes column with its quick-jump list is gone. Your notes are the same files and show up as combos; prompts saved from notes before still show in the Material Library and can be saved as a new combo.
|
||||
- **Put a combo on the canvas (搭配一键放到画布)**: **Put on canvas** on a combo's card or in its editor (formerly Send to canvas, under More) lays the combo out as a group of nodes wired together: its main model loader, its LoRAs and a positive and a negative prompt node; the group follows the pointer until you click, and Esc takes it off again (nothing is logged or left behind). The positive node holds the combo's prompt (left empty when it has none, rather than leaving the prompt nodes out) and the negative node gets Prompt Studio's common negative card. A combo card can also be dragged onto the canvas, and the group is built where it is dropped. Nothing already on the canvas is changed (to change an existing node's values, use Current node). A new group from a UNet combo connects its prompt nodes to the canvas's CLIP loader when there is exactly one, and says so once the group is down (the message no longer pops up while the group still follows the pointer, where closing it put the group down).
|
||||
- **Recipe detail, plainer (配方详情更清爽)**: the models a recipe uses are one list: preview, name, the node, whether this computer has it under the saved name, size, your note and its download page. The recipe's own "match local model / apply match" buttons, the identity badges, the advanced blocks with paths and hashes, the origin editor and the recipe fingerprint are gone: when the workflow opens, Model Check finds renamed or moved models and puts them in, and saving the recipe again keeps that (the old version is kept). The status says "Every model is on this computer" or "N models missing" with what happens next. The save dialog and the empty page speak plainly (no more "unclosed node chain" or "self-healing"), and the parameters page lost its save-to-library button on every node (pick nodes with "select some parameters" instead).
|
||||
- **Shared workflows say where their models come from (分享的工作流带上模型来源)**: saving a workflow or an image now also stores each model's download link (the one you set in the model editor, else its Civitai page) unless the workflow already keeps one, and drops links of models it no longer uses, so after Model Check swaps in your own model, what you export names your file, fingerprint and link while the workflow you opened stays as it was. GGUF, .pth and .sft models get fingerprints too. The share-code export says how many models carry a fingerprint, warns when some have none, and can leave the links out.
|
||||
|
||||
### ✍️ Prompts and Current node (提示词与当前节点)
|
||||
- **Prompt Studio edits the node itself (提示词工坊直接改节点)**: no more assembly board, "write to node" step or positive / negative tabs. Select a prompt node and its prompt boxes appear in the studio, positive and negative together, as tags: click one to make it stronger or weaker, double-click to edit it, drag to reorder, × to remove. Every change goes straight into the node, and the studio's **Undo** (or Ctrl+Z on the canvas) takes it back; line breaks in your prompt stay where they were. Type in a box and press Enter to add tags: Chinese, Japanese, Korean or any other language is translated to English first, and tags the box has already are not added twice; a box that holds words that are not English offers to translate them. The meaning menu in the top bar puts each tag's meaning under it in Simplified or Traditional Chinese, Japanese, Korean, Russian, Spanish, French, German or Portuguese; **Text** shows the whole text instead. With no prompt node selected you write a draft, which can be saved or dragged onto the canvas by its ⠿ handle; the studio keeps editing the last prompt node until you select another one or press the ✕ beside its name. A clicked card goes into the box of its role (a negative card never lands in a positive box). The six starter cards are down to three (quality, common negative, hands and anatomy), your saved prompts are under **My prompts** with their first words showing, and the studio no longer reads every saved prompt again every 30 seconds, only new ones.
|
||||
- **Saved prompts are managed in Prompt Studio (存过的提示词在提示词工坊里管理)**: the studio's library cards for prompts you saved used to be read-only ("managed by the source material"). Their preview now has **Rename** and **Delete** (the whole saved prompt, positive and negative together, to the Recycle Bin), and any card can be dragged out of the studio onto the canvas: onto a prompt box it fills that box (outlined by role, with the text previewed before you let go, one Ctrl+Z or Undo in the message to take it back), onto empty canvas it makes a prompt node.
|
||||
- **Prompt boxes found by what they are (提示词框按节点定义识别)**: dropping or writing a prompt (material library, Prompt Studio, translator) now finds a node's prompt boxes by ComfyUI's own definition (multiline text inputs) instead of guessing from names, so a filename or "value" box no longer takes a prompt and third-party prompt nodes are recognised. Whether a box is positive or negative follows its wiring to the sampler, not the node's title: a CLIP Text Encode connected to the negative input gets the negative prompt even if it is still called "CLIP Text Encode". Prompt Studio's "take prompts from the selected node" also reads the roles right again (it could not follow links in current ComfyUI).
|
||||
- **See where a dragged prompt goes (拖提示词前先看清楚落在哪)**: while you drag a prompt from the material library, every prompt box on the canvas is outlined (green for positive, red for negative, grey when it is not wired to a sampler) and the box under the pointer lights up. The label next to the pointer says exactly what releasing does, e.g. "Release: into #6's positive prompt box; the negative prompt box on the same sampler (#7) too". Drop on a box and that box is filled; the matching negative (or positive) box of the same sampler gets the other half of the material. On a node with several prompt boxes, point at the one you mean instead of letting it guess. A short message confirms what was filled; the library's receipt undoes it. Dropping a prompt plan on empty canvas used to create an empty prompt node; it now creates a positive and, when the plan has one, a negative node with their text.
|
||||
- **See it before you let go (拖的时候先看到结果)**: dragging a prompt over a prompt box shows, in that box and in the opposite box on the same sampler, the text releasing would put there; move away and they go back. Dragging saved values over a node of their kind lists what would change, such as "steps 12 → 28, cfg 4.5 → 5.5", or says nothing would change.
|
||||
- **Undo what you dropped (拖错了可以撤回)**: anything Anomalous writes on the canvas (a material dropped on a node, a prompt put in a box, values applied in Current node, a model swapped or a LoRA inserted, a recipe or combo sent to the canvas) is now one step of ComfyUI's own **Ctrl+Z**; before, a drop was not, and Ctrl+Z could undo the step before it instead. After a drop, the message at the top right has **Undo** and stays while the pointer is on it. Dropping a prompt on empty canvas can be undone the same way. In **Activity**, canvas changes made since the page was opened have **Undo** while nothing has changed them since; entries that removed nodes or rewired existing ones only record.
|
||||
- **Current node (当前节点, formerly Node Assistant)**: the panel no longer splits into "actions" and "parameter sets" tabs; it is one page with the model actions on top and **Parameters** below. Parameters lists every saved set of values for this kind of node in one list — from the material library and from recipes' parameter sets, the same values saved twice shown once — and each entry says exactly what it would change on the selected node ("steps: 20 → 30"). **Apply these N** changes only those; seeds and model files stay (change the model above), and the receipt undoes it. **Save these values** keeps the node's current values in the material library, so the next node of that kind can use them.
|
||||
- **Prompts on Current node (当前节点列出提示词框)**: Current node now lists the selected node's prompt boxes, each marked positive or negative by its wiring, and **Edit in Prompt Studio** opens them in the studio, where they are edited and translated. The separate Prompt Translator window is gone.
|
||||
|
||||
### 🩺 Model Check (模型检查)
|
||||
- **Model Check, formerly Model Doctor, redone (模型检查,原“模型医生”,重做)**: when a workflow opens with models this computer does not have, a bar over the canvas says how many, and how many are here under another name or folder; **Put in** swaps those in and **Show** opens the doctor. The doctor page lists what needs you first and says why in plain words (same file found elsewhere, same size only, not on this computer, no fingerprint saved in the workflow), with a button for each: put in, use it, pick one yourself, or look it up on Civitai; the models that are fine fold away. Opening the doctor no longer changes the workflow by itself, and a model put in by the doctor or its picker no longer stays marked red by ComfyUI. The "auto-detect on startup" switch (it did nothing) and the doctor's own deep hash scan are gone: the check hashes same-size files itself, and the page points to the scan page when unscanned models could be the missing ones.
|
||||
- **The Model Check bar can be turned off (模型检查横条可以关掉)**: the bar now starts with "Model Check:" so it is clear it comes from the browser, not ComfyUI, and has **Don't show again**, which stops checking workflows when they open and says where to turn it back on. The same switch is in Settings → Workflows ("Check for missing models when a workflow opens", on by default); with it off, run Model Check from the rail when you want it.
|
||||
- **A model put in by Model Check stays put (模型检查换上的模型不会再变回去)**: ComfyUI was not told about the change, so reloading the page right after **Put in** (or Ctrl+Z) brought back the old, missing name and its red mark. Each put-in is now one step of ComfyUI's history, saved like any edit of your own.
|
||||
- **Model Check finds renamed models by fingerprint again (按指纹找回改名的模型)**: when a workflow's model was renamed or moved and no scan had hashed the file yet, Model Check failed to hash the same-size files itself (an error in the background) and called the model missing. It now hashes them and finds it.
|
||||
- **Model Check is honest about same-name files (模型检查不再把同名当成同一个文件)**: for a workflow without fingerprints, a missing LoRA or checkpoint with a file of the same name on this computer was listed as "the same file under another name or folder" and counted among those to put in at once. It is now a candidate you take or leave on its row: "same name", or "same name and exactly the same size, very likely the one" (listed first). VAE, CLIP and other foundation components still go by fingerprint only. Model Check now also checks GGUF models and finds upscale models moved to another folder. Each one carries a level next to its name: ●●● sure (same fingerprint), ●●○ likely, ●○○ maybe, ○○○ unclear / does not match / not found.
|
||||
|
||||
### 🔍 Scanning and model information (扫描与模型信息)
|
||||
- **Scan page (扫描页)**: the rail's scan button and Home's "Scan model folders" open a page instead of the old seven-switch dialog. It shows how many models are matched on Civitai, not matched, and not scanned yet; one button scans the new ones, with progress in the page (it runs in the background, so the window can be closed). Offline scanning, renaming files on disk, fetching everything again, picking models and the Civitai API key are in **Scan settings**; the two risky switches turn themselves off after each scan. The "record model fingerprints in workflows" switch moved to Settings → Model settings.
|
||||
- **The scan page in plain words (扫描页说人话)**: it says what a scan does (the file's fingerprint is looked up on Civitai), and why a model was not found: not published on Civitai (voice, upscale and detection models mostly come from HuggingFace or GitHub), trained or merged by you, or changed after download; it works as usual, and you can add its download link. A model Civitai does not know that is no known image model says it may be a voice or upscale model. Model files in other formats (.pth, .ckpt, .bin…), which scans never read, are listed under **Other formats** with why, so they are no longer a mystery.
|
||||
- **A tidier scan page (扫描页更清爽)**: the scan page now holds only the counts, the scan button, the last scan in one card and a card saying how the next scan goes. **Not found**, **Not scanned yet**, the other formats and the last scan's models each open their own page with **‹ Back to scan**, a line on why those models are there, one button for all of them (scan these / look them up again) and the list; a model opened from it comes back to the same list where you were. **Show the result** after a scan opens the last scan's page. The old fold-out **Advanced** is now **Scan settings**, a page of its own like the lists: its card on the scan page shows how the next scan goes (all models or the picked ones, online or offline, renaming files…, with the ones that change files in yellow), and the page asks one question per card, which models as two choices side by side and each option with a switch, the ones that change files or take long marked so, the online-only ones under their own heading (faded for an offline scan).
|
||||
- **Unmatched models can be looked up again (没匹配上的模型可以重新查找)**: a model that Civitai did not know, or that was scanned while Civitai could not be reached, used to stay "local only" for good unless every model was fetched again (which also replaced your own covers). **Look up the N unmatched models again** retries just those, keeps your covers, and reuses the saved hash when the file is unchanged, so it takes seconds instead of re-hashing gigabytes.
|
||||
- **Which models, and what a scan did (是哪些模型、扫描做了什么)**: on the scan page, **Not matched** and **Not scanned yet** open a list of those models, with their type and folder; unmatched ones say why (Civitai does not have the file, Civitai could not be reached then, or it was an offline scan) and the base model inferred. Each row opens the model (Back returns to the scan page), scans it or looks it up again, or searches Civitai for its name. After a scan the page shows its result: when, how long, how many matched, inferred, failed, covers downloaded and files renamed, and each model it changed with what happened; the progress box says the same and, when it floats over the canvas, has **Show the result**. A model's card scan looks an unmatched model up again and its message says what it found.
|
||||
- **Online and offline in one scan (联网和离线在同一次扫描里)**: a scan asks Civitai first and infers the base model from the file for what Civitai does not know, as before; now it also remembers why. When Civitai stops answering (two models in a row), the rest of the scan infers from the files right away instead of waiting out every timeout, and the next online scan looks those models up again by itself, as it does for models from an offline scan. Models Civitai answered "not found" for wait for **Look up again**. A model that fails (a locked file, say) no longer ends the scan of the others; it is listed as failed with the reason.
|
||||
- **Your edits come first (你改的永远优先)**: names, notes and download links you set in the model editor now have a file of their own next to the model (`<model>.anomalous.json`) that scans never write, and they win over Civitai's information, which wins over what is inferred from the file. Edits saved by earlier versions keep working. Editing a model that was never scanned used to make it look scanned, so scans skipped it for good; it is now scanned like any new model.
|
||||
- **Covers you chose are never replaced (你换的封面不会被替换)**: a cover counts as Civitai's only while it is the very image the scan downloaded; any other cover is yours. **Fetch scanned models again** now keeps your covers (and names and notes), updates only Civitai's own, and asks before it starts.
|
||||
- **Where a model's information comes from (信息来源标注)**: cards look as before for models matched on Civitai; a base model inferred from the file shows as "≈ SDXL" with a dashed outline, and a model not scanned yet says so, each with a tooltip on what to do. The detail header adds the source (Civitai, inferred from the file, not scanned) and which fields you edited.
|
||||
- **Faster rescans (重复扫描更快)**: a scan no longer downloads every cover and Civitai model page again for models whose information is already on disk; only new or missing ones go to the network.
|
||||
- **Model types at the top (模型页顶部类型标签)**: the models page starts with a chip per model type (Checkpoint, LoRA, VAE…) and how many models it holds; a chip shows that whole type, subfolders included, so the grid no longer looks like it holds one folder's models. Picking a folder in the list narrows the grid to that folder, shown as a chip with ✕ that goes back to the whole type. The browser remembers the type you picked last.
|
||||
- **Model sources on the Models page (模型来源进了模型页)**: the Sources chip at the end of the model types now shows, in place of the cards, where each model is downloaded, for **This workflow** or **All models**, filtered by with / without a link. Every link can be typed or pasted straight into its row (any site, not only Civitai), found automatically, opened and saved with one button; a link is kept in the model's own information (the same as the editor's download link) and, for this workflow's models, in the workflow too, so it travels when the workflow is shared. The list can go on the canvas as a note or to the clipboard. Model Check's **Where to download** opens it on This workflow. The separate pop-up, its lock-then-edit links and its four ways of saving are gone.
|
||||
- **GGUF and .pth models show up (GGUF、.pth 模型能看到了)**: the Models page, the folder list's counts, search and model lookups only knew `.safetensors`, `.ckpt` and `.pt`, in lower case, so quantized GGUF models (Wan, Flux, Qwen…), most upscalers (`.pth`), `.sft` and `.bin` files and names like `MODEL.SAFETENSORS` were missing. They are all listed now. Scans still read only `.safetensors`; the others stay under **Not scanned yet**.
|
||||
|
||||
### 🗑️ Safer with your files (删除更安全)
|
||||
- **Deleting goes to the Recycle Bin (删除进回收站)**: deleting a model (with its info and covers), an output image, generated audio, a recipe (with its versions and images), a material, a prompt note or a parameter note, replacing or resetting a cover, and the duplicate copy a file rename finds, all move the files to the system Recycle Bin instead of erasing them. On a USB stick or network drive, which has no Recycle Bin, nothing is deleted and the message says to delete it by hand. The confirmations say where things go. An unused route that could erase every `.civitai.info` in the library (with the names you set) is gone.
|
||||
|
||||
### 🪶 Lighter on memory (更省内存)
|
||||
- **Model cards show up sooner (模型卡片出得更快)**: card thumbnails used to live in ComfyUI's temp folder, which ComfyUI empties on every start, so the first visit after each restart remade them all. They are now kept in ComfyUI's user folder (still at most 256 MB) and survive restarts. Opening a model type also makes the missing ones in the background, one at a time, so scrolling down finds them ready; the first screen of cards loads before the rest.
|
||||
- **Video covers show a still first (视频封面先显示第一帧)**: a video cover now shows its first frame right away and downloads nothing until it plays. Playing on hover is the default now (it was always-play, though the setting called hover "recommended"), so fewer videos decode at once; "Always play" is still in Settings.
|
||||
- The output gallery shows 512 px thumbnails instead of full images (cached like model cards), so scrolling through hundreds of outputs no longer fills the browser's memory and graphics memory with full-size pictures. Dragging an image to the canvas and the large viewer still use the original. When the browser has been closed for 90 seconds, its gallery images are released too, like the model grid.
|
||||
|
||||
### 🕘 Activity log (操作记录)
|
||||
- **See what Anomalous changed (看 Anomalous 改了什么)**: the new **Activity** page on the rail lists, by day, every change the plugin made: on the canvas, which node settings changed (from → to), which nodes were added or removed, and which workflow was opened; in files, models, covers, recipes, materials, notes, presets, images, audio and imported characters. Filter by canvas or files; open a canvas entry for its details and **Find on canvas** to jump to the node. Your own edits on the canvas are not recorded as the plugin's. The home page shows the latest few.
|
||||
- **More detail (记录更详细)**: a scan is recorded when it ends, with what it found and each model it changed (open one from the entry), instead of a bare "scanned models" when it started. Editing a model records each field from → to, and deleting one lists the files moved to the Recycle Bin.
|
||||
- Nothing is undone by itself (canvas entries have **Undo**, see "Undo what you dropped" above). **Clear** removes the log itself, never your changes. The newest 500 entries (at most 2 MB) are kept in ComfyUI's user folder.
|
||||
|
||||
### 🎙️ Audio & Voice Studio (音频与声音工作台)
|
||||
- **Characters and Voice-over, one page (角色和配音在同一页)**: the Script Director used to be a drawer opened by one of four equal buttons on Voices, over the character cards. Voices now has two views switched at the top, **Characters** and **Voice-over**, sharing the character list on the left: on Voice-over, clicking a character makes it the one who speaks. Each character card has **Voice-over** to go there with that character chosen; Home's "Give a character a voice" opens it too, and the rail's Voices entry reopens the view you used last. **Generate** is the big button; when it is done it says how long it took (instead of the node's count of sentences, batches and cache) and **Saved to Audio ›** opens the audio gallery. Pushing to a node, dragging onto one and copying sit below under **Into your own workflow**, and a push or drop is one Ctrl+Z step.
|
||||
- **Generation settings you can read (生成设置不再挤成一团)**: language is a row of choices and speed a slider, always in view; the four sampling numbers fold under **Fine-tuning (usually left alone)**, one per row with a plain name (Candidates, Randomness…) and the node's own name under it. The side no longer scrolls sideways.
|
||||
- **Calmer line cards (台词卡片更清爽)**: split, merge and delete show when the pointer is on a card; another take of a line stays in view. The ▶ that played the emotion's reference clip (easily taken for the generated line) is now a small speaker beside the emotion chips. A character with only its main voice shows no chips at all, just one line on how to add emotions. Plainer words: "Split into lines" instead of "Parse lines", "3 lines · using main voice" instead of "Bundled 3 lines".
|
||||
- **Audio gallery (音频库)**: each file's lines show once (the full text was always shown under the summary), 💬 opens them on Voice-over with their character for another take, and the list on the left no longer repeats the gallery as "Audio history".
|
||||
- **Generate right on the Voice-over page (配音页直接生成)**: for GPT-SoVITS characters, **Generate** runs the script in ComfyUI without touching the canvas (it waits in the queue while ComfyUI is busy), plays the result and saves it to `output/audio/<character>/`. **↻** on a line card gives just that line another take while the others come from cache; **New take of all** starts over with a new seed. Language, speed and the sampling parameters (one folded **Generation settings** line, with a hint on each) start from the character's saved defaults; when a take sounds right, **Save these settings as this character's default** keeps them for next time. The drawer is wider: the line cards get the full height with generating in a column beside them, and in a narrow or docked browser the drawer covers the studio with generating below the cards; either way only the cards scroll, however long the script is. Needs Anomalous_TTS interface 11.
|
||||
- **Pronunciation table (读音表)**: **Pronunciation** on a GPT-SoVITS character card lists what the script says and what the character should read instead (`C站 → 西站`); each line can be heard as the character reads it now and as replaced, without filling the audio gallery.
|
||||
- **Audio page tour (音频页遮罩导览)**: the tour offered in the audio page's "!" guide now walks through the audio page itself (import, dragging a character onto a node, emotions and pronunciation, emotion tags, Voice-over, audio gallery, GPT-SoVITS settings) instead of the image-side tour, whose buttons are not there. Tour steps whose button is not on screen are skipped.
|
||||
- **Suggested folder layout (推荐的文件夹放法)**: the "many characters" import screen shows, folded, how to arrange a folder so it sorts cleanly: one folder per character, named after it, with its GPT and SoVITS models and 3–10 s clips (and same-name line files) inside.
|
||||
- **Emotion tag buttons (情绪标签按钮)**: a clip's copy button now reads "Copy {happy}" and its tooltip says what pasting it into the speech text does; characters with only a main voice have no copy button, since their text needs no tag.
|
||||
- **Audio page tabs (音频页顶部标签)**: the "Audio Workflows" tab is gone from the audio page; generating now happens on the Voice-over page, and saved workflows stay under **Workflows** on the image side.
|
||||
- **Voice-over (配音)**: paste a script for one character; each sentence becomes a card with emotion chips, inline editing, split at cursor, merge and delete. Generate it right there (below), or push the tagged script to the selected GPT-SoVITS node or drag it onto one.
|
||||
- **Audio gallery (音频库)**: lists generated audio with its speech text, voice and seed read from the file itself, supports search, and can save unsaved previews into `output/audio/`.
|
||||
- **Clear drag rules (拖动规则明确)**: drag a character card's header onto a GPT-SoVITS node to set its character. The hint says what releasing will do; unsupported nodes are refused with the reason instead of being guessed.
|
||||
- **Usage guide (使用说明)**: in the audio page, the header **!** opens a five-step how-to; the full guide is `docs/guides/audio-studio.md`.
|
||||
- **GPT-SoVITS characters (GPT-SoVITS 角色)**: the audio page manages characters of the separate Anomalous_TTS node pack, detected at runtime; while it is missing, the page shows what it does, how to install it (ComfyUI Manager or the project page) and nothing else depends on it. The node's character menu has **Import or edit characters**, which opens this page on that character. Preview reference clips, pick a reference clip per emotion ("Edit emotions", saved through the node's API), drag a character onto the node, and write or generate scripts on the Voice-over page.
|
||||
- **Audio sidebar (音频侧栏)**: characters are grouped by language in foldable groups; a character unfolds into its clips, and a click plays one. Characters drag onto nodes from the sidebar too, a red dot marks characters that need a look, a search box appears from six characters on, and files dropped on a character open **Add files** for it.
|
||||
- **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: an empty studio is a drop area with **Import character**, which also sits in the toolbar. **GPT-SoVITS settings** at the bottom of the sidebar opens a dialog with where characters are kept, pretrained files and missing Python packages; missing pretrained files only get a small dot on that entry, with "don't remind me", once your characters need them, and the import form notes what the chosen language will download. **Batch import (批量导入)**: the import window takes one or many characters. It first asks what you have (one character, many characters or a package, or files for a character you already have), with three steps at the top and a spotlight tour the first time ("?" replays it). Each character card leads with a checklist that lights up the next step (GPT model, SoVITS model, clips, main voice, name) and offers its button right there; the card says how many steps are left. Drop files or whole folders (a GPT-SoVITS package too), or pick them in the browser; everything is uploaded to the node: a folder holding one set of GPT + SoVITS weights and its clips is one character named after the folder (two characters whose weights are both called `model` stay apart; `派蒙/v2` becomes "派蒙 v2"); weights in a package's weight folders pair up by name (latest epoch kept); clips and line files follow their character folder, the weights in their folder or the character their folder or file name points at; an annotation file (`.list`) no character claims goes to every character; two clips with the same name (`开心/01.wav`, `难过/01.wav`) both go in, the second as `难过_01.wav`; anything unclear waits in a card of its own, one line per folder that can be assigned at once, and is uploaded only once it is put into a character. Clips outside 3–10 s are left out with their line files. Python environments and base models (`runtime`, `pretrained_models`, `venv`…) are skipped, and a package's program and training folders (`GPT_SoVITS`, `logs`, `output`, `TEMP`, `tools`) only inside a package: your own folders called `output` or `GPT_SoVITS`, and the folder you chose or dropped, are always read; the window says which folders were skipped instead of files going missing quietly. A big add opens 20 characters (about 800 files) at a time; **Next batch** opens the rest, and it opens by itself once a batch is imported, so a huge folder does not freeze the window. Every clip has **▶** to listen while importing — uploading or unassigned — and clicking another clip switches to it for quick comparing. Long clip lists show the first 60, and a very large upload is confirmed first; uploads run three at a time. Each character is a card: one line with its name, a short status and what it holds, unfolding into its model tiles and clips; files move between characters with the picker on each row. **Import N characters** imports each ready one on its own, so one failure leaves the others alone. Status is colour-coded throughout: green ready, amber still missing something, red a failed or refused file, blue uploading; **Import** stays at the bottom. Adding to a character skips files it already has and merges annotation files (Anomalous_TTS interface 5). Characters are kept in one storage place (default `models/gpt_sovits`). It, other character folders and GPT-SoVITS packages to take pretrained files from are set in Anomalous_TTS's settings file, which the dialog names with a copy button: the node takes no folder or file path on this computer from the page, so a page cannot choose where folders are made or which files are read. Download pretrained files with progress. **Import character** builds a character from dropped or picked GPT / SoVITS weights, reference clips and annotation files: the name, SoVITS version, clip lengths and lines are filled in automatically, and everything is copied (originals stay). Drop files on a card, or use **Add files**, to add clips and emotions to an existing character. Lines are found in same-name `.txt` / `.lab` files, annotation files or, as a marked guess, the file name; a text file dropped on a clip's line box fills it. Needs Anomalous_TTS with interface version 13; file actions only work on the computer running ComfyUI.
|
||||
- The first "Import character" on a fresh install no longer says the storage place cannot be written to: the voices page asks GPT-SoVITS again before refusing, instead of trusting what it heard up to a minute earlier.
|
||||
- **A character voice dropped on a TTS node stays put (拖到 TTS 节点的角色声音刷新后还在)**: the same gap as above: reloading right after the drop brought back the node's previous voice, and Ctrl+Z skipped it. Every drop from the browser onto the canvas is now recorded as one history step.
|
||||
|
||||
### Fixes
|
||||
- **Models found offline no longer get their name doubled (离线推断的模型名字不再越叠越长)**: a model Civitai had no record of (or a scan without a connection) is named after its file, as both model and version. With renaming on, a scan renamed it to name_name, and each later scan doubled it again; the details showed "name - name". Such models now keep their file name, a doubled name set by an earlier virtual rename is dropped at the next scan, and the details show the name once. Files already renamed keep their current name (scanning again no longer lengthens it).
|
||||
- **The Workflows page was blank after opening a workflow (在新画布打开后再打开插件是空的)**: opening a workflow on the canvas (Open on a card or in its details) and then the browser again showed an empty page. The Workflows list is there again.
|
||||
- **Language follows ComfyUI again (语言跟随 ComfyUI)**: with the language left on "Follow ComfyUI" (the default), changing ComfyUI's language now switches the browser the next time it opens; before, it kept the old language until the page was reloaded.
|
||||
- **Empty list on the audio pages (音频页左边列表是空的)**: after reloading ComfyUI, a browser that reopened straight onto Voices or Audio showed an empty list column until you switched to the image side and back.
|
||||
- Prompt Studio's new card form showed "cancel" instead of 取消 on its Cancel button.
|
||||
- All-in-one loader recipes and cross-node prompt injection use one verified adapter table; prompt text never crosses between positive and negative.
|
||||
- The Workflows and Combos workspace no longer covers the header: the Models / Gallery / Workflows tabs stay clickable while it is open, and **Esc** closes it (back to what was open before) unless a dialog is above it or a text field has focus. Closing the Prompt Translator brings back the browser it folded away.
|
||||
- **Gallery pictures dragged to the canvas open their workflow again (图库拖到画布又能直接打开工作流)**: since the gallery shows small thumbnails, Chrome handed ComfyUI the thumbnail, which has no workflow in it, so only a Load Image node appeared. The original is dragged now: on an empty spot it opens the workflow, on a Load Image node it becomes that node's picture (full size, not the thumbnail).
|
||||
- **The picture details' thumbnail rail keeps its place (查看参数左边的缩略图不再重新翻一遍)**: picking a picture near the end loads the next gallery page; the rail used to be rebuilt and scroll down again from the top, now the new pictures are added below.
|
||||
- **Back and the tabs work on a scrolled page (页面往下滚后,返回和顶部标签点得动)**: on a combo's page and on a Workflow Recipe's details, once the content was scrolled under the header, clicks on **‹ All combos**, **← Back to recipes** and the recipe's tabs went to the model tiles or the cover hidden behind them (Chrome still counted those as being there). Both pages now give the click to what you see.
|
||||
- **Picking a model keeps your place (换模型时列表不再跳回顶部)**: in Current node's model picker, choosing a model further down redrew the whole list and scrolled back to the top (the model was chosen all the same). Only the chosen card's outline changes now.
|
||||
- **A combo is saved once, not once per tag (搭配不再一下存几十次)**: opening a combo's bilingual tags saved it once for nothing, then once more for every tag's translation as it came back, so a long prompt wrote the file (and the activity log) dozens of times in a second. The translations are now saved together, half a second after the last one.
|
||||
- **Activity shows where a long prompt changed (操作记录能看到长提示词改了哪里)**: values are kept to 400 characters, cut from the start, so an edit late in a long prompt showed the same text before and after. A long before/after pair is now cut around its first difference.
|
||||
- **Keeping a picture no longer changes the canvas (收藏图片不再改动画布上的参数)**: keeping a picture with ☆ as a workflow or a combo reads its workflow off the canvas, but when the canvas had the same workflow open (a picture dragged in, say), ComfyUI took the two for one and the canvas's values became the picture's. The workflow is now read under a name of its own.
|
||||
|
||||
## 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.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
|
||||
- **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
|
||||
- **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
|
||||
@@ -115,7 +401,11 @@
|
||||
- **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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Claude 工作入口
|
||||
|
||||
本仓库的开发与维护规范以 AGENTS.md 为唯一正文,第 0 节是每次提交都必须做到的要求;这里只做入口,不另写规则。
|
||||
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,11 @@
|
||||
# Gemini 工作入口
|
||||
|
||||
在本插件中修改任何文件前,先阅读并遵守根目录的 [AGENTS.md](AGENTS.md)。它是本仓库开发与维护规范的唯一正文,适用于 UX 调整、修复和重构;第 0 节是每次提交都必须做到的要求。
|
||||
|
||||
随后阅读 [ARCHITECTURE.md](ARCHITECTURE.md),按其中的阅读地图加载与当前任务有关的专题。
|
||||
|
||||
不要只凭当前任务描述直接追加实现。先确认现有责任模块、状态来源、关闭清理和样式规则;完成后按规范验证并报告结果。
|
||||
|
||||
如果当前工具没有加载本文件,任务发起者应明确要求 Gemini 阅读 `GEMINI.md` 和 `AGENTS.md`。文件的存在不代表工具已实际读取,更不代表检查已自动执行。
|
||||
|
||||
前端设计任务按 AGENTS.md 第 4 节和 [前端架构](docs/architecture/frontend.md) 进行,不使用历史视觉建议作为当前任务单。
|
||||
@@ -1,159 +1,218 @@
|
||||
# Anomalous Model Browser
|
||||
<div align="center">
|
||||
|
||||
[](CHANGELOG.md) [](https://github.com/ltdrdata/ComfyUI-Manager)
|
||||
# 🚀 Anomalous Model Browser
|
||||
|
||||
[English](#english) | [中文](#中文)
|
||||
**A model browser and creative workspace for ComfyUI**
|
||||
*模型库 · 出图图库 · 工作流配方 · 搭配 · 提示词 · 角色配音 · 模型检查 · 操作记录*
|
||||
|
||||
<br/>
|
||||
|
||||
[](https://github.com/ltdrdata/ComfyUI-Manager)
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
[](https://youtu.be/hAvsj7uiaCw)
|
||||
|
||||
<br/>
|
||||
|
||||
[**English**](#english) | [**中文说明**](#中文)
|
||||
|
||||
<br/>
|
||||
|
||||
<img src="docs/images/models.webp" width="900" alt="Models with their Civitai covers, base model and real names">
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<h2 id="english">🇬🇧 English</h2>
|
||||
|
||||
> A comprehensive Creative Workspace and Model Manager for ComfyUI. Features zero-dependency Civitai scraping, an auto-resolving Model Doctor, a visual Node Assistant, lossless Workflow Exchange, and reusable Workflow Recipes with Parameter Notebooks.
|
||||
> **Anomalous Model Browser** takes care of the chores around ComfyUI in one window: browse your models with their Civitai covers and trigger words, look back through your outputs, keep workflows you have tuned, keep model combos and prompts, repair workflows whose models are missing, and, with the companion node pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS), give your characters a voice. An activity log shows everything the plugin changed.
|
||||
|
||||
### 🎬 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/)
|
||||
|
||||
### 👀 See it in action
|
||||
|
||||
*The pictures show the Chinese interface; English is one switch away in Settings.*
|
||||
|
||||
**1. Scan once, then pick models by their covers.** Press **Scan** and every model is looked up on Civitai by its file hash, so even a file called `下载 (3).safetensors` gets its real name, cover, trigger words and base model.
|
||||
|
||||
<p align="center"><img src="docs/images/scan.webp" width="520" alt="Scan results: each file matched on Civitai"></p>
|
||||
|
||||
**2. Open someone else's workflow without hunting for models.** When models are missing, a bar over the canvas says so. One press puts back the ones you already have under another name or in another folder; the red outlines go away.
|
||||
|
||||
<p align="center"><img src="docs/images/model-check.webp" width="760" alt="Model Check puts back three missing models with one press"></p>
|
||||
|
||||
**3. Find an old image and get its workflow back.** Search the gallery by prompt words, model or LoRA, then drag the image onto the canvas.
|
||||
|
||||
<p align="center"><img src="docs/images/gallery.webp" width="760" alt="Gallery search, then drag an image onto the canvas"></p>
|
||||
|
||||
**4. Keep a model + LoRA + prompt combo and lay it down in one move.** Drag a combo card onto empty canvas: a wired group of nodes appears, and nothing already on the canvas changes.
|
||||
|
||||
<p align="center"><img src="docs/images/combo.webp" width="760" alt="A combo card dragged onto the canvas becomes wired nodes"></p>
|
||||
|
||||
**5. Edit prompts as tags.** Select a prompt node and Prompt Studio opens its text as tags: click to weight, drag to reorder, type to add (Chinese and other languages become English).
|
||||
|
||||
<p align="center"><img src="docs/images/prompt-studio.webp" width="760" alt="Prompt Studio editing a prompt node as tags"></p>
|
||||
|
||||
**6. See what the plugin changed, and undo it.** Every change the plugin makes is listed in Activity, with **Undo** and **Find on canvas**.
|
||||
|
||||
<p align="center"><img src="docs/images/activity.webp" width="560" alt="Activity log with an undo button"></p>
|
||||
|
||||
**7. Give characters a voice** (with [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)): GPT-SoVITS characters with a reference clip per emotion, and a script page to voice them.
|
||||
|
||||
<p align="center"><img src="docs/images/voices.webp" width="760" alt="Character voices page"></p>
|
||||
|
||||
### 🌟 What it does
|
||||
|
||||
Every page has an icon on the rail at the left of the window.
|
||||
|
||||
| Page | What you do there |
|
||||
| :--- | :--- |
|
||||
| **🏠 Home** | Pick what you want to do: a card per task, first steps (scan your model folders, a guided tour of the interface) and your latest activity. |
|
||||
| **🕘 Activity** | Everything the plugin changed, by day: on the canvas (which node settings changed from what to what, nodes added or removed, workflows opened) and in files (models, covers, recipes, materials, notes, images, audio). **Find on canvas** jumps to the node; a canvas change can be undone with **Undo** until something changes it again, the rest is recorded only. Your own edits are not listed. |
|
||||
| **📦 Models** | Your models with covers, trigger words and base model. Chips at the top switch between types (Checkpoint, LoRA, VAE…) and list a whole type, subfolders included; the folder list narrows it to one folder. **+** adds a loader node to the canvas; edit a model's name, notes and cover, or scan just that model. |
|
||||
| **🖼️ Gallery** | Your ComfyUI `output` folder. Search by prompt, model, LoRA, seed, file name or model hash; open an image to see the parameters it was made with, or drag it onto the canvas to get its workflow back. **☆** keeps a good one as a whole workflow, a combo (model, LoRAs, prompt) or just its prompts. |
|
||||
| **🪡 Workflows** | Workflow recipes: save a whole workflow or a part of one together with its models, cover, notes and parameters. Each card shows whether the models are on this computer; drag it onto the canvas to load it. Versions can be compared and restored. |
|
||||
| **🧩 Combos** | A main model, a few LoRAs and a prompt you like, kept together. **Put on canvas** (or dragging the card onto the canvas) lays them out as a wired group of nodes; nothing already on the canvas changes. |
|
||||
| **✍️ Prompts** | Opens Prompt Studio beside the canvas. Select a prompt node and its prompt boxes appear as tags, edited in place: click a tag to make it stronger or weaker, double-click to edit, drag to reorder, type to add (Chinese, Japanese and other languages are translated to English), and show each tag's meaning in Chinese, Japanese, Korean and other languages. Click a card (a few common ones, plus your saved prompts) to add it, or drag it straight onto a prompt box on the canvas (outlined green for positive, red for negative) or onto empty canvas for a new prompt node. With no prompt node selected you write a draft. |
|
||||
| **🎙️ Voices / 🎧 Audio** | With [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) installed: import GPT-SoVITS characters, pick a reference clip per emotion, fix pronunciations, write a script under Voice-over on the same page and generate it right there; generated audio is listed in the audio gallery. Without it, the page explains how to install it and nothing else depends on it. |
|
||||
|
||||
Tools on the rail:
|
||||
|
||||
* **Scan** reads your model folders and fetches covers, trigger words and base models from Civitai (by file hash, no extra Python packages).
|
||||
* **Model Check** (formerly Model Doctor) checks each workflow you open: when models are missing, a bar over the canvas says so and puts back, with one press, the ones you have under another name or folder (recognised by hash and file size); its page lists the rest with what you can do.
|
||||
* **Current node** (formerly Node Assistant) shows the model of the node you select, lets you swap it from the covers or insert a LoRA, and lists every set of values saved for that kind of node with exactly what applying it would change; saved values can be deleted there.
|
||||
* **Each tool on its page**: workflow share codes (import / export) on Workflows, Model Sources (where each model can be downloaded) on Models and in Model Check, translation on the prompt boxes in Current node.
|
||||
* **Settings** (at the bottom): language, font size, thumbnails, video covers, which model folders to show, and feedback. Home and Settings have **Report a problem**, which opens a GitHub issue with your versions and GPU already filled in.
|
||||
|
||||
**Light on your computer**: the plugin itself loads no models, so it takes no graphics memory from image generation; the browser shows small thumbnails instead of full images, and its pictures are released a minute and a half after you close it. The voice models of Anomalous_TTS give their graphics memory back whenever ComfyUI needs it for image generation, and all of it on **Unload models**.
|
||||
|
||||
### 📦 Quick Installation
|
||||
|
||||
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. For character voices, also install [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) the same way. It is optional.
|
||||
4. **Updating**: click the **!** button at the top right of the browser; it shows the current version, and **Check for updates / switch version** opens the version panel. Update to the newest release, go back to an earlier one or return to the latest, then restart ComfyUI. The plugin never checks for updates on its own.
|
||||
|
||||
Open the browser with **Shift + B** (again to close), the floating button on the canvas, or **Extensions → Anomalous Model Browser** in ComfyUI's menu. **ComfyUI Settings → Anomalous Model Browser → Interface** changes the shortcut, the language and how the browser is opened (floating button, a button next to Run, or the Extensions menu only).
|
||||
|
||||
The first time you open it, start on **Home**: **Scan model folders** fills in covers and trigger words, and **Tour the interface** points at each part of the window.
|
||||
|
||||
> [!NOTE]
|
||||
> **Network:** Scan looks models up on Civitai and prompt translation uses Google Translate, so both need to reach those sites (from mainland China usually through a proxy). Without them, pick **Offline scan**: base models are read from the files, without covers or trigger words, and a later online scan fills in the rest. Browsing models, outputs, workflows and combos needs no network.
|
||||
|
||||
> [!WARNING]
|
||||
> **Beta features and data protection:** Workflow Recipes and only the recipe-powered **Parameter Presets** tab inside Node Assistant are currently in testing; the Node Assistant's **Actions** tab is not part of this beta. Before updating the plugin, importing someone else's recipe, restoring or deleting recipe data, or applying saved parameters, save the current canvas and back up `workflows/anomalous_recipes` and `workflows/anomalous_parameters` inside your ComfyUI user directory. Recipe model-preview snapshots do not contain model files and are not model backups.
|
||||
> **Keep a copy of your own data:** Workflow Recipes, combos, saved prompts and node values, and parameter presets live in `workflows/anomalous_recipes`, `workflows/anomalous_notebooks`, `workflows/anomalous_materials` and `workflows/anomalous_parameters` in your ComfyUI user folder. A copy of them before updating the plugin is a good habit.
|
||||
|
||||
### 🌟 Core Capabilities
|
||||
---
|
||||
|
||||
| 🚀 Feature | 💡 Description |
|
||||
<h2 id="中文">🇨🇳 中文说明</h2>
|
||||
|
||||
> **Anomalous Model Browser** 在一个窗口里帮你打理 ComfyUI 周边的杂事:
|
||||
> - 带 C 站封面和触发词浏览模型;
|
||||
> - 翻看以前的出图;
|
||||
> - 保存调好的工作流,存下常用的模型搭配和提示词;
|
||||
> - 别人的工作流缺模型时自动找回;
|
||||
> - 配合配套节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 给角色配音。
|
||||
>
|
||||
> 插件改动过什么,都能在“操作记录”里查到。
|
||||
|
||||
### 🎬 视频演示与教程
|
||||
* 📺 **哔哩哔哩 (Bilibili)**:[在 B 站观看快速上手与使用演示](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
* 📺 **YouTube**:[在 YouTube 观看视频演示](https://youtu.be/hAvsj7uiaCw)
|
||||
|
||||
### 👀 看图上手
|
||||
|
||||
**1. 扫描一次,以后看封面挑模型。** 点 **扫描**,每个模型都按文件哈希去 C 站认一遍。就算文件叫 `下载 (3).safetensors`,也能显示出它真正的名字、封面、触发词和底模。
|
||||
|
||||
<p align="center"><img src="docs/images/scan.webp" width="520" alt="扫描结果:每个文件都在 C 站认出来了"></p>
|
||||
|
||||
**2. 打开别人的工作流,不用自己找模型。** 缺模型时画布上方会提示。你电脑里其实有、只是改了名或换了文件夹的,点一下就换上,红框随之消失。
|
||||
|
||||
<p align="center"><img src="docs/images/model-check.webp" width="760" alt="模型检查一键换上 3 个缺失的模型"></p>
|
||||
|
||||
**3. 找到以前的图,拿回当时的工作流。** 在图库里按提示词、模型或 LoRA 搜,再把图拖到画布上。
|
||||
|
||||
<p align="center"><img src="docs/images/gallery.webp" width="760" alt="图库搜索后把图拖到画布"></p>
|
||||
|
||||
**4. 存下“模型 + LoRA + 提示词”的搭配,一拖就用。** 把搭配卡片拖到画布空白处,就放出一组连好线的节点,画布上原有的东西不动。
|
||||
|
||||
<p align="center"><img src="docs/images/combo.webp" width="760" alt="搭配卡片拖到画布变成连好线的节点"></p>
|
||||
|
||||
**5. 像搭积木一样改提示词。** 选中提示词节点,提示词工坊把它拆成词块:点一下调权重,拖动换位置,输入就加(中文会自动译成英文)。
|
||||
|
||||
<p align="center"><img src="docs/images/prompt-studio.webp" width="760" alt="提示词工坊按词块编辑提示词节点"></p>
|
||||
|
||||
**6. 插件改过什么都查得到,还能撤销。** 插件做的每一处改动都列在“操作记录”里,带 **撤销** 和 **在画布上找到**。
|
||||
|
||||
<p align="center"><img src="docs/images/activity.webp" width="560" alt="操作记录和撤销按钮"></p>
|
||||
|
||||
**7. 给角色配音**(需要 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)):导入 GPT-SoVITS 角色,每种情绪一段参考音频,在配音页写台词直接生成。
|
||||
|
||||
<p align="center"><img src="docs/images/voices.webp" width="760" alt="角色语音页面"></p>
|
||||
|
||||
### 🌟 能做什么
|
||||
|
||||
窗口左侧的图标栏,每个页面一个图标。
|
||||
|
||||
| 页面 | 在这里做什么 |
|
||||
| :--- | :--- |
|
||||
| **Comprehensive Model Management** | Automatically extract Base Model architectures, cover images, trigger words, and author info by scanning model hashes. Customize model names, notes, and covers. Safely delete unwanted models or quickly replace them visually with cover icons. |
|
||||
| **Workflow Node Repair (🩺)** | As long as a workflow/image was exported with this plugin active, the **Model Doctor (🩺)** can auto-detect missing nodes and replace them with correct local paths. Stubborn models? Use "Deep Hash Scan" or get Civitai links. |
|
||||
| **Node Assistant & Recipe Presets (🤖)** | Select a canvas node to visually replace a model, safely insert a LoRA into a compatible chain, or load same-type node parameters saved by Workflow Recipes. A saved KSampler note, for example, can apply sampler, scheduler, steps, CFG, and denoise in one click while volatile seeds are ignored. |
|
||||
| **Workflow Recipes (🧰)** | Save a complete workflow with a cover, notes, tags, model identity, prompts, sampler settings, node parameters, matching output images, and version history. Append recipes to the canvas, compare output/version parameters, match foreign model references to local files, and import or export portable recipe packages. |
|
||||
| **Smart Notebook (📑)** | Leveraging scanned local architectures, it offers rapid matching between Checkpoints, UNet, and compatible LoRAs. Features a built-in translation tool, auto-chunks prompts into tags, and supports 1-click canvas deployment. |
|
||||
| **History Gallery (🖼️)** | Natively reads your local `output` folder. Supports 1-click viewing, deleting, and mouse-wheel zooming. Drag and drop any generated image directly onto the canvas to instantly load its embedded workflow! |
|
||||
| **Additional Features** | Click the top-right `➕` of any model card to deploy it to the canvas. The UI supports side-docking to leave space for your canvas operations. |
|
||||
| **🏠 主页** | 想做什么就点哪张卡片。还有上手第一步(扫描模型文件夹、界面导览)和最近的操作记录。 |
|
||||
| **🕘 记录** | 按天列出插件做过的每一处改动。<br>画布上:哪个节点的设置从什么改成了什么、加了或删了哪些节点、打开了哪个工作流。<br>文件上:模型、封面、配方、素材、笔记、图片、音频。<br>**在画布上找到** 可以直接跳到那个节点;画布上的改动在没被再改过之前可以点 **撤销**,其余只记录。你自己在画布上的修改不会记进来。 |
|
||||
| **📦 模型** | 模型带封面、触发词和底模信息。<br>顶部标签切换类型(Checkpoint、LoRA、VAE…),连同子文件夹一起列出;左侧文件夹列表可以只看某个文件夹。<br>**+** 一键在画布上创建加载节点;也可以改名、写备注、换封面,或只扫描这一个模型。 |
|
||||
| **🖼️ 图库** | 读取 ComfyUI 的 `output` 文件夹。<br>可以按提示词、模型、LoRA、seed、文件名或模型哈希搜索。<br>点开图片能看到生成参数;拖到画布上可以还原当时的工作流。<br>觉得好就点 **☆**:存成整个工作流、搭配(模型 + LoRA + 提示词),或者只存提示词。 |
|
||||
| **🪡 工作流** | 工作流配方:把完整工作流或其中一段,连同模型、封面、备注和参数一起存下来。<br>卡片会显示这台电脑上模型是否齐全;拖到画布上即可载入。<br>可以比较、恢复历史版本。 |
|
||||
| **🧩 搭配** | 把常用的主模型、几个 LoRA 和一段提示词存成一个搭配。<br>按 **放到画布**(或把卡片拖到画布上),就放出一组连好线的节点;画布上原有的东西不动。 |
|
||||
| **✍️ 提示词** | 在画布旁边打开提示词工坊。选中一个提示词节点,它的提示词框就以词块的样子出现在工坊里,直接改:点一下调权重,双击改字,拖动换位置,输入就加(中文、日文等会自动译成英文),还能在每个词下面显示它的中文、日文、韩文等语言的意思。<br>点词卡(几张常用的,加上你存的)加进去,或者直接拖到画布的提示词框里(正面绿框、负面红框),拖到空白处就新建一个提示词节点。没选中提示词节点时写的是草稿。 |
|
||||
| **🎙️ 角色语音 / 🎧 音频库** | 装了 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后可用:<br>导入 GPT-SoVITS 角色,给每种情绪选参考音频,修正读音;切到“配音”写台词并直接生成。<br>生成的音频都在音频库里。没装时,页面会说明怎么安装,其他功能不受影响。 |
|
||||
|
||||
### 📖 Step-by-Step Operating Guide
|
||||
图标栏上的工具:
|
||||
|
||||
#### 1. Initialization (Scan Wizard) 🔄
|
||||
* **Location**: The Scan icon (**🔄**) at the bottom left of the plugin interface.
|
||||
* **Operation**: This is the most important prerequisite. Open the Scan Wizard, select your configuration, and execute the scan. Ensure your network is connected.
|
||||
* **Result**: This activates all core features of the plugin by building your local model database.
|
||||
* **扫描**:读取模型文件夹,按文件哈希从 C 站获取封面、触发词和底模。不需要额外安装 Python 包。
|
||||
* **模型检查**(原“模型医生”):打开工作流时自动检查,缺模型就在画布上方提示;本地改过名、换过文件夹的同一个文件(按哈希和文件大小认)一键换上,其余的在检查页里自己挑或去 Civitai 找。
|
||||
* **当前节点**(原“节点助手”):选中画布上的节点,查看它用的模型。可以看图换模型、插入 LoRA;存过的同类节点参数列在一起,每条写明会改哪几项,一键套用;存下的参数也在这里删除。
|
||||
* **工具回到各自的页面**:工作流分享码(导入导出)在“工作流”页,模型来源(每个模型去哪下载)在“模型”页和模型检查里,翻译在“当前节点”的提示词框上。
|
||||
* **设置**(最下面):语言、字号、缩略图、视频封面、显示哪些模型文件夹,以及提交反馈。主页和设置里的 **报告问题** 会打开一个已经填好版本和显卡信息的 GitHub 页面。
|
||||
|
||||
#### 2. Workflow Repair (Model Doctor) 🩺
|
||||
* **Location**: The second Stethoscope icon (**🩺**) from the bottom left.
|
||||
* **Operation**: When you import someone else's workflow and the nodes turn red due to missing paths, click the Model Doctor. It will intelligently identify all broken nodes and replace them with your correct local paths.
|
||||
**不占你的资源**:
|
||||
- 插件本身不加载任何模型,不和生图抢显存。
|
||||
- 浏览时显示的是小缩略图,不加载原图;关闭窗口一分半后,图片也会释放掉。
|
||||
- Anomalous_TTS 的语音模型在 ComfyUI 生图需要显存时会自动让出,点 **卸载模型** 时全部释放。
|
||||
|
||||
#### 3. Visual Swapping (Node Assistant) 🤖
|
||||
* **Location**: The third Robot icon (**🤖**) from the bottom left.
|
||||
* **Actions**: Select a node on the ComfyUI canvas. For model nodes, the Actions tab can visually replace the current model or insert a LoRA before/after a compatible MODEL + CLIP chain. Picker cards support previews, model-category/base-model badges, folders, full-path search, sorting, and safe handling of ambiguous branches.
|
||||
* **Parameter Presets (Beta)**: Switch to **Parameter Presets** to read parameter notes associated with Workflow Recipes. Presets are grouped by recipe and contain saved values for the same node type as the selected node. Clicking a preset applies it transactionally to that one node; runtime-changing values such as seeds are ignored.
|
||||
### 📦 快速安装
|
||||
|
||||
#### 4. Workflow Recipes & Parameter Notebooks 🧰
|
||||
* **Location**: Open **Creative Workspace (📑)** and switch from **Prompt Notes** to **Workflow Recipes**.
|
||||
* **Save and reuse**: Save the complete current graph with a cover, name, tags, notes, model references, prompts, sampler settings, and bounded node summaries. Recipes can be appended to the current canvas without replacing it.
|
||||
* **Details**: Overview shows model composition and reproducibility information. Models can be matched to local files by hash, size, and model category rather than the author's path; Civitai origin names/links and local previews can be retained when available.
|
||||
* **Parameter Notebook**: Browse the recipe's saved values, copy or expand long fields, create an edited parameter note, or read a fresh matching canvas into a new note. **Apply to Current Workflow** first checks that the recipe skeleton matches, then updates safe widgets; volatile values such as seeds are ignored. Stored notes can be deleted individually. Unknown third-party prompt roles can be labelled manually.
|
||||
* **Gallery and Versions**: Find historical outputs with the same node structure, open an image to compare parameter differences, set an output as the recipe cover, compare recipe versions, or restore an older version while archiving the current one.
|
||||
* **Sharing**: Export/import portable recipe packages with optional model-preview snapshots and history. Identity hashes can be removed during export. A preview snapshot is presentation data only—it never includes a model file.
|
||||
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. 想用角色配音的话,用同样的方法再装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)。不装也不影响其他功能。
|
||||
4. **更新插件**:
|
||||
- 点窗口右上角的 **!**,可以看到当前版本;
|
||||
- 点 **检查更新 / 切换版本** 打开版本面板,可以更新到最新版、回退到以前的版本,或回到最新版;
|
||||
- 换完版本后重启 ComfyUI。插件不会自己联网检查更新。
|
||||
|
||||
#### 5. Settings Panel ⚙️
|
||||
* **Location**: The Gear icon (**⚙️**) at the bottom left.
|
||||
* **Operation**: Adjust the UI language and main UI font size. Open **Model Settings** to choose always-play or hover-play video covers and optimized card thumbnails or original covers. Detail pages continue to use the original cover. **Folder Manager** can hide folders from both the sidebar and background reads, and reorder them. **Help** contains the current safety and feature guide.
|
||||
打开方式有三种:按 **Shift + B**(再按一次关闭)、点击画布上的悬浮按钮,或者从 ComfyUI 菜单 **扩展 → Anomalous Model Browser** 打开。在 **ComfyUI 设置 → Anomalous Model Browser → 界面** 里,可以修改快捷键、插件语言和打开方式(悬浮按钮、运行按钮旁的按钮,或只用扩展菜单)。
|
||||
|
||||
#### 6. Top Navigation & Prompt Notes 📑
|
||||
**Location**: The top tabs: **Models (📦)**, **Gallery (🖼️)**, **Creative Workspace (📑)**, and **Dock Side (◧)**.
|
||||
第一次打开时会停在 **主页**:点 **扫描模型文件夹** 补全封面和触发词;点 **界面导览** 会逐一指给你看窗口的各个部分。
|
||||
|
||||
**Prompt Notes Operation**:
|
||||
1. Open **Creative Workspace (📑)**, choose **Prompt Notes**, then click the **➕ (New)** button and confirm.
|
||||
2. Select a Base Model (e.g., SD1.5). You can then select compatible Main Models and LoRAs.
|
||||
3. Click **📝 Edit Raw / Paste** to paste your prompts. Select the language at the top-left for bilingual translation.
|
||||
4. After confirming, the plugin will automatically chunk your prompt into tags. You can find/replace or directly edit tags.
|
||||
5. Click **🚀 Send to Canvas** to automatically wire and deploy the Checkpoint, LoRA, and CLIP Text Encode directly to your canvas!
|
||||
|
||||
---
|
||||
|
||||
### 📦 Installation
|
||||
|
||||
Open your terminal, go to your ComfyUI `custom_nodes` folder, and run:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
> **Note**: Restart ComfyUI after cloning. Alternatively, search for `Anomalous Model Browser` inside the ComfyUI Manager and click Install!
|
||||
|
||||
---
|
||||
|
||||
<h2 id="中文">🇨🇳 中文</h2>
|
||||
|
||||
> 一个综合性的 ComfyUI 创作工作台与模型管家。集成零依赖的 C 站信息抓取、支持自动修复的模型医生、可视化节点助手、无损工作流交互,以及支持参数复用的工作流配方与笔记本体系。
|
||||
> [!NOTE]
|
||||
> **关于网络:** 扫描模型要连 Civitai(C 站)查封面和触发词,提示词翻译默认用谷歌翻译,这两样在国内一般要开网络代理才能用。不开代理也行:扫描时选 **离线扫描**,只从模型文件推断底模,没有封面和触发词,以后能连上时再扫一次会自动补上。浏览模型、图库、工作流和搭配都不用联网。
|
||||
|
||||
> [!WARNING]
|
||||
> **测试功能与数据保护:** 工作流配方以及节点助手中由配方驱动的“参数笔记本”目前属于测试功能;节点助手的“动作”页不在测试范围内。更新插件、导入他人配方、恢复或删除配方数据、应用已保存参数之前,请先保存当前画布,并备份 ComfyUI 用户目录中的 `workflows/anomalous_recipes` 与 `workflows/anomalous_parameters`。配方中的模型预览快照不包含模型文件,不能替代模型备份。
|
||||
|
||||
### 🌟 核心功能特性
|
||||
|
||||
| 🚀 功能板块 | 💡 详细说明 |
|
||||
| :--- | :--- |
|
||||
| **全方位模型管理** | 通过扫描哈希值,自动获取模型的基础架构、封面、提示词、作者简介等信息。支持自定义名称、备注和封面。点开模型即刻呈现封面图标,支持看图一键替换模型,彻底告别复杂的模型路径! |
|
||||
| **节点智能修复 (🩺)** | 凡是由本插件导出的工作流和图片,在载入他人工作流时出现节点爆红,只需点开**模型医生 (🩺)**,即可自动识别正确的模型路径并一键替换。内置深度扫描与 C 站直达链接。 |
|
||||
| **节点助手与配方参数 (🤖)** | 选中画布节点后,可视化替换模型、向兼容链路安全插入 LoRA,或读取工作流配方保存的同类型节点参数。例如保存过的 K 采样器笔记可一键应用采样器、调度器、步数、CFG 与降噪,同时忽略易变的种子。 |
|
||||
| **工作流配方 (🧰)** | 保存完整工作流及封面、备注、标签、模型身份、提示词、采样参数、节点参数、匹配出图与版本历史。支持追加到画布、比较出图/版本参数、将他人的模型引用匹配到本地文件,以及导入导出便携配方包。 |
|
||||
| **智能笔记本 (📑)** | 基于本地基础架构,提供快速的架构匹配功能,极速选择兼容的主模型与 LoRA。内置强大的翻译功能,自动将提示词分块打上标签,实现双语对照,并支持一键发布组装到工作流画布中。 |
|
||||
| **原生图库管理 (🖼️)** | 无缝读取本地的 `output` 文件夹。支持鼠标滚轮缩放、一键查看和安全删除。更绝的是,您可以**直接将图片拖动到画布上,瞬间原地加载内嵌的工作流!** |
|
||||
| **其它便捷功能** | 点击模型卡片右上角的 `➕` 号,快捷将节点发布到画布。支持侧边栏停靠 (Dock),将界面吸附在左侧,为您的画布留出充足的操作空间。 |
|
||||
|
||||
### 📖 标准操作指南
|
||||
|
||||
为了让插件发挥最大效能,请按照以下流程进行操作:
|
||||
|
||||
#### 1. 前置准备 (扫描向导) 🔄
|
||||
* **具体位置**:界面左侧底部的 **扫描图标 (🔄)**。
|
||||
* **操作步骤**:这是最重要的一步!请先打开扫描向导,根据需求选择扫描配置并执行扫描(切记保持网络畅通)。扫描完成后,您的本地数据库建立完毕,插件的各项核心功能全面激活。
|
||||
|
||||
#### 2. 拯救爆红 (模型医生) 🩺
|
||||
* **具体位置**:左侧底部第二个 **听诊器按钮 (🩺)**。
|
||||
* **操作步骤**:当导入别人使用该插件输出的工作流或者图片时,若发现模型路径爆红报错,点击模型医生,它就能智能识别报错节点,并一键实现正确路径的替换。
|
||||
|
||||
#### 3. 选中交互 (节点助手) 🤖
|
||||
* **具体位置**:左侧底部第三个 **机器人按钮 (🤖)**。
|
||||
* **动作**:打开后在 ComfyUI 画布上选中节点。对于模型节点,“动作”页可以可视化替换当前模型,或在兼容的 MODEL + CLIP 链前后插入 LoRA。选择器提供模型预览、模型类别/基础模型标记、文件夹、完整路径搜索、排序与多分支安全保护。
|
||||
* **参数笔记本(测试)**:切换到“参数笔记本”,可以读取与工作流配方关联的参数笔记。内容会按配方分组,只展示和当前选中节点类型相同的保存记录。点击一条记录即可事务式应用到该节点;种子等每次运行都会变化的值会被忽略。
|
||||
|
||||
#### 4. 工作流配方与参数笔记本 🧰
|
||||
* **具体位置**:打开 **创作工作台 (📑)**,从“提示词笔记”切换到 **工作流配方**。
|
||||
* **保存与复用**:保存当前完整节点图,并附带封面、名称、标签、备注、模型引用、提示词、采样设置与受限节点摘要。配方可以追加到当前画布,不会覆盖当前内容。
|
||||
* **详细信息**:概览展示模型组成与复现信息。模型匹配根据哈希、文件大小与模型类别查找本地文件,不依赖原作者路径;可用时还会保留 C 站官方名称/链接与本地预览。
|
||||
* **参数笔记本**:查看配方保存的参数,复制或展开长内容,新建可编辑参数笔记,或者从骨架匹配的当前画布读取全新参数。点击“应用到当前工作流”时会先检查配方骨架,再更新安全控件;种子等易变值会被忽略。保存的参数笔记可以单独删除;无法可靠识别的第三方提示词节点可以手动标注正负角色。
|
||||
* **图库与版本**:查找节点结构相同的历史出图,打开图片比较参数差异,将出图设为配方封面,比较配方历史版本,或在自动归档当前版本后恢复旧版本。
|
||||
* **分享**:导入导出便携配方包,可选择包含模型预览快照与历史版本,也可在导出时移除身份哈希。预览快照只是展示数据,绝不会包含模型文件。
|
||||
|
||||
#### 5. 个性化配置 (设置面板) ⚙️
|
||||
* **具体位置**:左侧底部的 **齿轮按钮 (⚙️)**。
|
||||
* **操作步骤**:在此可以调节语言和主页面字体大小。进入 **模型设置** 后,可选择视频封面始终播放或悬停播放,也可选择卡片使用流畅缩略图或原始封面;模型详情页始终使用原始封面。**文件夹管理**可以让指定目录同时退出侧栏和后台读取,并支持调整顺序;**帮助**中会持续更新安全提醒与功能说明。
|
||||
|
||||
#### 6. 顶部导航与提示词笔记 📑
|
||||
**具体位置**:右侧顶部的按钮分别为 **模型 (📦)**、**图库 (🖼️)**、**创作工作台 (📑)**、**停靠侧边栏 (◧)**。
|
||||
|
||||
**提示词笔记操作步骤**:
|
||||
1. 打开**创作工作台 (📑)**,进入“提示词笔记”,点击新建 **➕** 号并确认。
|
||||
2. 首先选择基础模型(例如 SD1.5),系统会过滤出兼容的主模型与对应的 LoRA 模型供你选择。
|
||||
3. 点击下方的 **📝 纯文本/粘贴** 复制提示词进去,选择左上角的语言即可实现双语对照翻译。
|
||||
4. 确认后,插件会自动生成标签 (Tags),支持查找替换,也支持直接双击修改单个标签。
|
||||
5. 最后,点击下方的 **🚀 发送到画布**,系统会将打包好的主模型、LoRA 和 CLIP,一键全自动连线发布到画布!
|
||||
|
||||
---
|
||||
|
||||
### 📦 安装指南
|
||||
|
||||
打开命令行终端,进入 ComfyUI 的 `custom_nodes` 文件夹,执行以下命令:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
> **注意**:克隆完成后,重启 ComfyUI 即可。您也可以直接在 ComfyUI Manager(管理器)中搜索 `Anomalous Model Browser` 并一键点击安装!
|
||||
> **备份自己的数据:** 工作流配方、搭配、存下的提示词和节点参数、参数预设存在 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_notebooks`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters` 文件夹。更新插件前顺手备份一份,更安心。
|
||||
|
||||
---
|
||||
|
||||
### 📝 License & Branding (开源与品牌声明)
|
||||
|
||||
**Code License (代码授权)**
|
||||
The source code of this project is released under the [MIT License](LICENSE). You are free to use, modify, and distribute the code.
|
||||
本项目的源代码基于 MIT 许可证开源,您可以自由地使用、修改和分发代码。
|
||||
|
||||
**Branding & Trademarks (品牌与商标保护)**
|
||||
The MIT License covers the repository's code, documentation, stylesheets, and ordinary UI resources. The name **Anomalous Model Browser** and the official project logo identify official releases; the MIT License does not imply that a modified distribution is official or endorsed. Publicly distributed forks should use a distinct primary name and logo, while they may truthfully say that they are based on Anomalous Model Browser. See the [Trademark and Brand Policy](TRADEMARKS.md).
|
||||
|
||||
MIT 许可证适用于本仓库的代码、文档、样式表和普通 UI 资源。名称 **Anomalous Model Browser** 与官方项目 Logo 用于识别官方版本;采用 MIT 许可证并不表示修改后的发行版属于官方版本或获得认可。公开分发的修改版本应使用不同的主要名称与 Logo,但可以如实说明其基于 Anomalous Model Browser。详见[商标与品牌政策](TRADEMARKS.md)。
|
||||
* **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)。
|
||||
|
||||
@@ -5,7 +5,8 @@ from server import PromptServer
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
# 注册 API 路由
|
||||
setup_routes(PromptServer.instance.app)
|
||||
if hasattr(PromptServer, "instance") and PromptServer.instance is not None:
|
||||
setup_routes(PromptServer.instance.app)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
@@ -1,71 +1,125 @@
|
||||
from .models import *
|
||||
from .scanner import *
|
||||
from .config import *
|
||||
from .notebooks import *
|
||||
from .parameters import *
|
||||
from .recipes import *
|
||||
from .recipe_packages import *
|
||||
from .utils import *
|
||||
|
||||
def setup_routes(app):
|
||||
app.router.add_get('/anomalous/folders', api_get_folders)
|
||||
app.router.add_get('/anomalous/all_folder_types', api_get_all_folder_types)
|
||||
app.router.add_get('/anomalous/models', api_get_models)
|
||||
app.router.add_get('/anomalous/all_scan_models', api_get_all_scan_models)
|
||||
app.router.add_get('/anomalous/batch_select', api_batch_select)
|
||||
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/find_model', 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', 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)
|
||||
|
||||
# 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/delete_parameter', api_delete_parameter)
|
||||
app.router.add_get('/anomalous/parameter_gallery', api_get_parameter_gallery)
|
||||
|
||||
# Recipe Routes
|
||||
app.router.add_get('/anomalous/recipes', api_get_recipes)
|
||||
app.router.add_get('/anomalous/recipe_full', api_get_recipe_full)
|
||||
app.router.add_get('/anomalous/recipe_asset', api_get_recipe_asset)
|
||||
app.router.add_get('/anomalous/recipe_gallery', api_get_recipe_gallery)
|
||||
app.router.add_get('/anomalous/recipe_parameter_gallery', api_get_recipe_parameter_gallery)
|
||||
app.router.add_get('/anomalous/recipe_gallery_compare', api_get_recipe_gallery_compare)
|
||||
app.router.add_post('/anomalous/save_recipe', api_save_recipe)
|
||||
app.router.add_post('/anomalous/update_recipe', api_update_recipe)
|
||||
app.router.add_post('/anomalous/set_recipe_gallery_cover', api_set_recipe_gallery_cover)
|
||||
app.router.add_post('/anomalous/delete_recipe', api_delete_recipe)
|
||||
app.router.add_get('/anomalous/recipe_history', api_get_recipe_history)
|
||||
app.router.add_get('/anomalous/recipe_version', api_get_recipe_version)
|
||||
app.router.add_post('/anomalous/restore_recipe_version', api_restore_recipe_version)
|
||||
app.router.add_post('/anomalous/refresh_recipe_identity', api_refresh_recipe_identity)
|
||||
app.router.add_post('/anomalous/export_recipe_package', api_export_recipe_package)
|
||||
app.router.add_post('/anomalous/import_recipe_package_inspect', api_import_recipe_package_inspect)
|
||||
app.router.add_post('/anomalous/import_recipe_package_commit', api_import_recipe_package_commit)
|
||||
|
||||
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_post('/anomalous/resolve_hash_batch', api_resolve_hash_batch)
|
||||
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)
|
||||
app.router.add_get('/anomalous/scan_missing_models_status', api_scan_missing_models_status)
|
||||
app.router.add_post('/anomalous/clear_cache', api_clear_cache)
|
||||
app.router.add_post('/anomalous/update_metadata', api_update_metadata)
|
||||
app.router.add_post('/anomalous/set_custom_cover', api_set_custom_cover)
|
||||
app.router.add_post('/anomalous/upload_custom_cover', api_upload_custom_cover)
|
||||
app.router.add_get('/anomalous/model_images', api_get_model_images)
|
||||
app.router.add_post('/anomalous/resolve_paths_to_previews', api_resolve_paths_to_previews)
|
||||
app.router.add_post('/anomalous/scan_missing_models', api_scan_missing_models)
|
||||
from . import (
|
||||
activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog,
|
||||
model_folder, model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_report, scan_summary,
|
||||
local_guard, translation_routes, version_manager,
|
||||
)
|
||||
from .scanner import *
|
||||
from .config import *
|
||||
from .notebooks import *
|
||||
from .parameters import *
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
EXTENSION_STATIC_PREFIX = '/extensions/Anomalous_Model_Browser/'
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def no_cache_extension_middleware(request, handler):
|
||||
"""Make browsers revalidate this plugin's JS/CSS so a normal refresh picks up updates.
|
||||
|
||||
`no-cache` still allows 304 responses via ETag/Last-Modified. This replaces
|
||||
per-import `?v=` query strings, which load a second module instance whenever
|
||||
two files import the same module with different URLs.
|
||||
"""
|
||||
response = await handler(request)
|
||||
if request.path.startswith(EXTENSION_STATIC_PREFIX):
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
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)
|
||||
# Other devices on the network may look; only this computer may change things (local_guard.py).
|
||||
if hasattr(app, 'middlewares') and local_guard.local_only_middleware not in app.middlewares:
|
||||
app.middlewares.append(local_guard.local_only_middleware)
|
||||
app.router.add_get('/anomalous/access', local_guard.api_access)
|
||||
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/type_models', model_type_listing.api_get_type_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_get('/anomalous/card_cache', media_routes.api_card_cache)
|
||||
app.router.add_post('/anomalous/card_cache/clear', media_routes.api_clear_card_cache)
|
||||
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/open_model_folder', model_folder.api_open_model_folder)
|
||||
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_prompt_plan', materials.api_save_prompt_plan)
|
||||
app.router.add_post('/anomalous/save_node_material', node_material.api_save_node_material)
|
||||
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/mark_material_moved', materials.api_mark_material_moved)
|
||||
|
||||
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/output_thumbnail', gallery_routes.api_output_thumbnail)
|
||||
app.router.add_get('/anomalous/kept_images', kept_images.api_kept_images)
|
||||
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_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_get('/anomalous/scan_summary', scan_summary.api_scan_summary)
|
||||
app.router.add_get('/anomalous/last_scan', scan_report.api_last_scan)
|
||||
app.router.add_get('/anomalous/scan_model', scan_report.api_scan_model)
|
||||
|
||||
# Audio & Voice Studio Routes
|
||||
app.router.add_get('/anomalous/audio_stream', audio_catalog.api_serve_audio)
|
||||
app.router.add_get('/anomalous/audio_gallery', audio_catalog.api_get_audio_gallery)
|
||||
app.router.add_post('/anomalous/delete_audio_gallery', audio_catalog.api_delete_audio_gallery)
|
||||
app.router.add_get('/anomalous/audio_previews', audio_catalog.api_get_audio_previews)
|
||||
app.router.add_post('/anomalous/save_audio_preview', audio_catalog.api_save_audio_preview)
|
||||
version_manager.register_routes(app)
|
||||
activity_log.register_routes(app)
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Activity log: what Anomalous changed, newest first, kept in the ComfyUI user folder.
|
||||
|
||||
Two sources feed it. The middleware records this plugin's own write requests
|
||||
(and Anomalous_TTS's) after they succeed: files such as models, covers, recipes,
|
||||
materials, notes, presets, images and audio. The browser posts the canvas changes
|
||||
it made (`POST /anomalous/activity`). Only the newest MAX_ENTRIES are kept and long
|
||||
values are shortened, so the file stays small. Recording never fails a request:
|
||||
the log is a convenience, not part of the action.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
MAX_ENTRIES = 500
|
||||
MAX_LOG_BYTES = 2 * 1024 * 1024 # long canvas entries could otherwise outgrow the JSON writer's limit
|
||||
MAX_VALUE_CHARS = 400
|
||||
DIFF_CONTEXT_CHARS = 60 # kept before the first difference of a long before/after pair
|
||||
MAX_CHANGES = 60
|
||||
|
||||
# Write routes worth a line in the log → the action id the browser has words for. Scans are
|
||||
# recorded when they end, with what they found (api/scan_report.py), not here.
|
||||
ROUTE_ACTIONS = {
|
||||
'/anomalous/delete_model': 'model_delete',
|
||||
'/anomalous/update_metadata': 'model_edit',
|
||||
'/anomalous/set_custom_cover': 'model_cover',
|
||||
'/anomalous/upload_custom_cover': 'model_cover',
|
||||
'/anomalous/save_recipe': 'recipe_save',
|
||||
'/anomalous/update_recipe': 'recipe_edit',
|
||||
'/anomalous/delete_recipe': 'recipe_delete',
|
||||
'/anomalous/restore_recipe_version': 'recipe_restore',
|
||||
'/anomalous/set_recipe_gallery_cover': 'recipe_cover',
|
||||
'/anomalous/import_recipe_package_commit': 'recipe_import',
|
||||
'/anomalous/save_image_material': 'material_save',
|
||||
'/anomalous/save_prompt_plan': 'material_save',
|
||||
'/anomalous/update_material': 'material_edit',
|
||||
'/anomalous/delete_material': 'material_delete',
|
||||
'/anomalous/save_notebook': 'note_save',
|
||||
'/anomalous/delete_notebook': 'note_delete',
|
||||
'/anomalous/save_parameter': 'preset_save',
|
||||
'/anomalous/rename_parameter': 'preset_rename',
|
||||
'/anomalous/delete_parameter': 'preset_delete',
|
||||
'/anomalous/delete_gallery_image': 'image_delete',
|
||||
'/anomalous/delete_audio_gallery': 'audio_delete',
|
||||
'/anomalous/save_audio_preview': 'audio_save',
|
||||
'/anomalous/clear_cache': 'cache_clear',
|
||||
'/anomalous_tts/import/commit': 'voice_import',
|
||||
'/anomalous_tts/settings': 'voice_settings',
|
||||
}
|
||||
# Request fields that name what was changed, in order of preference.
|
||||
TARGET_FIELDS = ('name', 'character', 'filename', 'target_filename', 'recipe_filename',
|
||||
'notebook_filename', 'new_name', 'path', 'file', 'target')
|
||||
|
||||
# What a handler attaches to its request for the log: request[ACTIVITY_DETAIL] = {fields, files}.
|
||||
# web.RequestKey is newer than the aiohttp some ComfyUI installs have; a plain key works there.
|
||||
ACTIVITY_DETAIL = web.RequestKey("activity_detail", dict) if hasattr(web, "RequestKey") else "activity_detail"
|
||||
|
||||
_lock = threading.RLock()
|
||||
_entries = None
|
||||
|
||||
|
||||
def log_path():
|
||||
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')
|
||||
directory = os.path.join(user_dir, 'anomalous')
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
return os.path.join(directory, 'activity_log.json')
|
||||
|
||||
|
||||
def _load():
|
||||
global _entries
|
||||
if _entries is None:
|
||||
try:
|
||||
with open(log_path(), encoding='utf-8') as source:
|
||||
data = json.load(source)
|
||||
_entries = [entry for entry in data if isinstance(entry, dict)] if isinstance(data, list) else []
|
||||
except (OSError, ValueError):
|
||||
_entries = []
|
||||
return _entries
|
||||
|
||||
|
||||
def _encoded_size(entries):
|
||||
return len(json.dumps(entries, ensure_ascii=False, separators=(',', ':')).encode('utf-8'))
|
||||
|
||||
|
||||
def _trim(entries):
|
||||
"""Drop the oldest entries until the list fits MAX_ENTRIES and MAX_LOG_BYTES."""
|
||||
del entries[MAX_ENTRIES:]
|
||||
while len(entries) > 1 and _encoded_size(entries) > MAX_LOG_BYTES:
|
||||
del entries[max(1, len(entries) * 9 // 10):]
|
||||
|
||||
|
||||
def _text(value):
|
||||
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _short(value):
|
||||
text = _text(value)
|
||||
return text if len(text) <= MAX_VALUE_CHARS else text[:MAX_VALUE_CHARS] + '…'
|
||||
|
||||
|
||||
def _short_values(item, keys):
|
||||
"""`keys` of `item` shortened. A long before/after pair is cut around its first difference
|
||||
instead of at the start, so a change late in a long prompt still shows."""
|
||||
out = {key: _short(item[key]) for key in keys if key in item and item[key] is not None}
|
||||
if 'before' not in out or 'after' not in out:
|
||||
return out
|
||||
old, new = _text(item['before']), _text(item['after'])
|
||||
if len(old) <= MAX_VALUE_CHARS and len(new) <= MAX_VALUE_CHARS:
|
||||
return out
|
||||
same = 0
|
||||
while same < min(len(old), len(new)) and old[same] == new[same]:
|
||||
same += 1
|
||||
start = max(0, same - DIFF_CONTEXT_CHARS)
|
||||
|
||||
def cut(text):
|
||||
return ('…' if start else '') + text[start:start + MAX_VALUE_CHARS] + ('…' if start + MAX_VALUE_CHARS < len(text) else '')
|
||||
out['before'], out['after'] = cut(old), cut(new)
|
||||
return out
|
||||
|
||||
|
||||
def add_entry(source, action, target='', detail=None):
|
||||
"""Append one entry (newest first) and save. Returns the entry."""
|
||||
entry = {
|
||||
'id': uuid.uuid4().hex[:12],
|
||||
'time': time.time(),
|
||||
'source': source,
|
||||
'action': action,
|
||||
'target': _short(target or ''),
|
||||
}
|
||||
if detail:
|
||||
entry['detail'] = detail
|
||||
with _lock:
|
||||
entries = _load()
|
||||
entries.insert(0, entry)
|
||||
_trim(entries)
|
||||
atomic_write_json(log_path(), entries)
|
||||
return entry
|
||||
|
||||
|
||||
def list_entries(limit=100, source=None):
|
||||
with _lock:
|
||||
entries = _load()
|
||||
picked = [entry for entry in entries if not source or entry.get('source') == source]
|
||||
return picked[:limit]
|
||||
|
||||
|
||||
def clear():
|
||||
global _entries
|
||||
with _lock:
|
||||
_entries = []
|
||||
atomic_write_json(log_path(), [])
|
||||
|
||||
|
||||
def _target_of(body):
|
||||
if not isinstance(body, dict):
|
||||
return ''
|
||||
for field in TARGET_FIELDS:
|
||||
value = body.get(field)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return os.path.basename(value.strip().rstrip('/\\')) or value.strip()
|
||||
return ''
|
||||
|
||||
|
||||
def _failed(response):
|
||||
"""True when a response reports failure, including the 200 + {"status": "error"} style."""
|
||||
if response.status >= 300:
|
||||
return True
|
||||
if getattr(response, 'content_type', '') != 'application/json' or not getattr(response, 'body', None):
|
||||
return False
|
||||
try:
|
||||
data = json.loads(response.body)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return isinstance(data, dict) and (data.get('success') is False or data.get('ok') is False
|
||||
or data.get('status') in ('error', 'failed'))
|
||||
|
||||
|
||||
async def _request_body(request):
|
||||
"""The body the handler already read (aiohttp caches it); {} when it was not JSON or a form."""
|
||||
try:
|
||||
if request.content_type == 'application/json':
|
||||
return await request.json()
|
||||
if request.content_type in ('multipart/form-data', 'application/x-www-form-urlencoded'):
|
||||
return dict(await request.post())
|
||||
except (ValueError, UnicodeDecodeError, RuntimeError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _clean_detail(detail):
|
||||
"""What a handler attached for the log (request[ACTIVITY_DETAIL]): the fields it changed
|
||||
({field, before, after}) and the files it moved, values shortened."""
|
||||
if not isinstance(detail, dict):
|
||||
return None
|
||||
cleaned = {}
|
||||
if isinstance(detail.get('fields'), list):
|
||||
cleaned['fields'] = [_short_values(item, ('field', 'before', 'after'))
|
||||
for item in detail['fields'][:MAX_CHANGES] if isinstance(item, dict)]
|
||||
if isinstance(detail.get('files'), list):
|
||||
cleaned['files'] = [_short(name) for name in detail['files'][:MAX_CHANGES]]
|
||||
return {key: value for key, value in cleaned.items() if value} or None
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def activity_middleware(request, handler):
|
||||
response = await handler(request)
|
||||
action = ROUTE_ACTIONS.get(request.path) if request.method == 'POST' else None
|
||||
if action and not _failed(response):
|
||||
try:
|
||||
target = _target_of(await _request_body(request))
|
||||
detail = _clean_detail(request.get(ACTIVITY_DETAIL))
|
||||
await asyncio.get_running_loop().run_in_executor(None, add_entry, 'file', action, target, detail)
|
||||
except Exception: # noqa: BLE001 - the log must never fail the action it records
|
||||
logging.getLogger(__name__).warning('Anomalous activity log: could not record %s', request.path, exc_info=True)
|
||||
return response
|
||||
|
||||
|
||||
def _clean_changes(changes):
|
||||
cleaned = []
|
||||
for change in changes[:MAX_CHANGES]:
|
||||
if not isinstance(change, dict):
|
||||
continue
|
||||
item = _short_values(change, ('kind', 'node', 'type', 'widget', 'before', 'after'))
|
||||
if item.get('kind') in ('changed', 'added', 'removed', 'opened'):
|
||||
cleaned.append(item)
|
||||
return cleaned
|
||||
|
||||
|
||||
async def api_get_activity(request):
|
||||
try:
|
||||
limit = max(1, min(MAX_ENTRIES, int(request.query.get('limit', '100'))))
|
||||
except ValueError:
|
||||
limit = 100
|
||||
source = request.query.get('source') or None
|
||||
entries = await asyncio.get_running_loop().run_in_executor(None, list_entries, limit, source)
|
||||
return web.json_response({'entries': entries})
|
||||
|
||||
|
||||
async def api_post_activity(request):
|
||||
"""Canvas changes from the browser: {changes: [...], total, workflow}."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
return web.json_response({'error': 'Invalid JSON body'}, status=400)
|
||||
if not isinstance(body, dict) or not isinstance(body.get('changes'), list):
|
||||
return web.json_response({'error': 'changes must be a list'}, status=400)
|
||||
changes = _clean_changes(body['changes'])
|
||||
if not changes:
|
||||
return web.json_response({'error': 'No recognizable changes'}, status=400)
|
||||
total = body.get('total') if isinstance(body.get('total'), int) else len(changes)
|
||||
detail = {'changes': changes, 'total': max(total, len(changes))}
|
||||
entry = await asyncio.get_running_loop().run_in_executor(
|
||||
None, add_entry, 'canvas', 'canvas', body.get('workflow') if isinstance(body.get('workflow'), str) else '', detail)
|
||||
return web.json_response({'entry': entry})
|
||||
|
||||
|
||||
async def api_clear_activity(request):
|
||||
await asyncio.get_running_loop().run_in_executor(None, clear)
|
||||
return web.json_response({'ok': True})
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
if hasattr(app, 'middlewares') and activity_middleware not in app.middlewares:
|
||||
app.middlewares.append(activity_middleware)
|
||||
app.router.add_get('/anomalous/activity', api_get_activity)
|
||||
app.router.add_post('/anomalous/activity', api_post_activity)
|
||||
app.router.add_post('/anomalous/activity/clear', api_clear_activity)
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Generated audio: the audio gallery, Preview Audio results, and streaming them."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
|
||||
from .audio_metadata import generation_info
|
||||
from .path_utils import resolve_within
|
||||
from .trash import move_to_trash, trash_failure
|
||||
|
||||
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.flac', '.ogg', '.opus', '.m4a'}
|
||||
AUDIO_CONTENT_TYPES = {
|
||||
'.wav': 'audio/wav',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.flac': 'audio/flac',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.opus': 'audio/ogg',
|
||||
'.m4a': 'audio/mp4',
|
||||
}
|
||||
AUDIO_ROOTS = {
|
||||
"output": lambda: folder_paths.get_output_directory(),
|
||||
"temp": lambda: folder_paths.get_temp_directory(),
|
||||
}
|
||||
PREVIEW_LIMIT = 30
|
||||
SAVED_PREVIEW_SUBFOLDER = "audio"
|
||||
# Revalidate instead of forbidding caches: overwritten files get a new ?v=mtime URL.
|
||||
AUDIO_CACHE_HEADERS = {"Cache-Control": "no-cache"}
|
||||
|
||||
|
||||
def _audio_url(rel_path, dir_type, version):
|
||||
return f"/anomalous/audio_stream?path={quote(rel_path, safe='/')}&type={dir_type}&v={int(version)}"
|
||||
|
||||
|
||||
async def api_serve_audio(request):
|
||||
"""GET /anomalous/audio_stream?path=...&type=output|temp - Stream an audio file."""
|
||||
rel_path = request.query.get("path", "").strip("/\\")
|
||||
dir_type = request.query.get("type", "output")
|
||||
if not rel_path:
|
||||
return web.Response(status=400, text="Missing audio path")
|
||||
if dir_type not in AUDIO_ROOTS:
|
||||
return web.Response(status=400, text="Unknown audio folder")
|
||||
|
||||
base_dir = AUDIO_ROOTS[dir_type]()
|
||||
try:
|
||||
resolved_path = resolve_within(base_dir, rel_path)
|
||||
except ValueError:
|
||||
return web.Response(status=403, text="Forbidden path")
|
||||
|
||||
ext = os.path.splitext(resolved_path)[1].lower()
|
||||
if ext not in AUDIO_EXTENSIONS:
|
||||
return web.Response(status=415, text="Not an audio file")
|
||||
if not os.path.isfile(resolved_path):
|
||||
return web.Response(status=404, text="Audio file not found")
|
||||
|
||||
return web.FileResponse(resolved_path, headers={"Content-Type": AUDIO_CONTENT_TYPES[ext], **AUDIO_CACHE_HEADERS})
|
||||
|
||||
|
||||
def _collect_audio_files(root_dir, dir_type):
|
||||
"""Scan a folder tree for audio files, newest first."""
|
||||
audios = []
|
||||
if not os.path.isdir(root_dir):
|
||||
return audios
|
||||
|
||||
for root, _, files in os.walk(root_dir):
|
||||
for name in files:
|
||||
if os.path.splitext(name)[1].lower() not in AUDIO_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
stat = os.stat(os.path.join(root, name))
|
||||
except OSError:
|
||||
continue
|
||||
subfolder = os.path.relpath(root, root_dir)
|
||||
clean_sub = "" if subfolder == "." else subfolder.replace(os.sep, '/')
|
||||
rel_path = "/".join(part for part in (clean_sub, name) if part)
|
||||
audios.append({
|
||||
"filename": name,
|
||||
"subfolder": clean_sub,
|
||||
"size_bytes": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"audio_url": _audio_url(rel_path, dir_type, stat.st_mtime),
|
||||
})
|
||||
audios.sort(key=lambda a: a["mtime"], reverse=True)
|
||||
return audios
|
||||
|
||||
|
||||
_info_cache = {}
|
||||
|
||||
|
||||
def _cached_generation_info(path, mtime, size):
|
||||
"""Generation info keyed by (mtime, size) so unchanged files are parsed once."""
|
||||
cached = _info_cache.get(path)
|
||||
if cached and cached[0] == (mtime, size):
|
||||
return cached[1]
|
||||
info = generation_info(path)
|
||||
if len(_info_cache) > 5000:
|
||||
_info_cache.clear()
|
||||
_info_cache[path] = ((mtime, size), info)
|
||||
return info
|
||||
|
||||
|
||||
def _attach_generation_info(root_dir, items):
|
||||
for item in items:
|
||||
path = os.path.join(root_dir, item["subfolder"], item["filename"])
|
||||
item["generation"] = _cached_generation_info(path, item["mtime"], item["size_bytes"])
|
||||
return items
|
||||
|
||||
|
||||
def _search_text(item):
|
||||
speech = (item.get("generation") or {}).get("speech") or ""
|
||||
return f"{item['subfolder']}/{item['filename']}\n{speech}".lower()
|
||||
|
||||
|
||||
def _gallery_page(output_dir, page, limit, query):
|
||||
all_audios = _collect_audio_files(output_dir, "output")
|
||||
if query:
|
||||
# Searching reads every file's comments once; the cache keeps later searches cheap.
|
||||
_attach_generation_info(output_dir, all_audios)
|
||||
all_audios = [item for item in all_audios if query in _search_text(item)]
|
||||
total = len(all_audios)
|
||||
start = (page - 1) * limit
|
||||
items = all_audios[start:start + limit]
|
||||
if not query:
|
||||
_attach_generation_info(output_dir, items)
|
||||
return items, total, start + limit < total
|
||||
|
||||
|
||||
def _positive_int(value, default, maximum=None):
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
number = max(1, number)
|
||||
return min(maximum, number) if maximum else number
|
||||
|
||||
|
||||
async def api_get_audio_gallery(request):
|
||||
"""GET /anomalous/audio_gallery?page=1&limit=50&q=text - Generated audio in output, with the speech that produced it."""
|
||||
page = _positive_int(request.query.get("page"), 1)
|
||||
limit = _positive_int(request.query.get("limit"), 40, 100)
|
||||
query = request.query.get("q", "").strip().lower()
|
||||
|
||||
items, total, has_more = await asyncio.to_thread(_gallery_page, folder_paths.get_output_directory(), page, limit, query)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"audios": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"has_more": has_more,
|
||||
})
|
||||
|
||||
|
||||
def _list_previews(temp_dir):
|
||||
items = _collect_audio_files(temp_dir, "temp")[:PREVIEW_LIMIT]
|
||||
return _attach_generation_info(temp_dir, items)
|
||||
|
||||
|
||||
async def api_get_audio_previews(request):
|
||||
"""GET /anomalous/audio_previews - Unsaved preview audio in ComfyUI's temp folder (cleared on restart)."""
|
||||
items = await asyncio.to_thread(_list_previews, folder_paths.get_temp_directory())
|
||||
return web.json_response({"success": True, "audios": items})
|
||||
|
||||
|
||||
def _save_preview(temp_dir, output_dir, subfolder, filename):
|
||||
source = resolve_within(temp_dir, subfolder, filename)
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext not in AUDIO_EXTENSIONS:
|
||||
raise ValueError("Only audio files can be saved")
|
||||
if not os.path.isfile(source):
|
||||
raise FileNotFoundError(filename)
|
||||
|
||||
dest_dir = resolve_within(output_dir, SAVED_PREVIEW_SUBFOLDER)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(os.path.getmtime(source)))
|
||||
for counter in range(1, 1000):
|
||||
name = f"Preview_{stamp}_{counter:02}{ext}"
|
||||
target = os.path.join(dest_dir, name)
|
||||
try:
|
||||
# O_EXCL: never overwrite an existing file in the library.
|
||||
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0))
|
||||
except FileExistsError:
|
||||
continue
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out, open(source, "rb") as src:
|
||||
shutil.copyfileobj(src, out)
|
||||
except Exception:
|
||||
try:
|
||||
os.remove(target)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
stat = os.stat(target)
|
||||
rel_path = f"{SAVED_PREVIEW_SUBFOLDER}/{name}"
|
||||
item = {
|
||||
"filename": name,
|
||||
"subfolder": SAVED_PREVIEW_SUBFOLDER,
|
||||
"size_bytes": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"audio_url": _audio_url(rel_path, "output", stat.st_mtime),
|
||||
}
|
||||
return _attach_generation_info(output_dir, [item])[0]
|
||||
raise FileExistsError("No free file name")
|
||||
|
||||
|
||||
async def api_save_audio_preview(request):
|
||||
"""POST /anomalous/save_audio_preview {filename, subfolder} - Copy a temp preview into output/audio."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
|
||||
filename = str(data.get("filename", "")).strip()
|
||||
subfolder = str(data.get("subfolder", "")).strip().strip("/\\")
|
||||
if not filename or os.path.basename(filename) != filename:
|
||||
return web.json_response({"success": False, "error": "Invalid filename"}, status=400)
|
||||
try:
|
||||
item = await asyncio.to_thread(
|
||||
_save_preview, folder_paths.get_temp_directory(), folder_paths.get_output_directory(), subfolder, filename)
|
||||
except ValueError as e:
|
||||
return web.json_response({"success": False, "error": str(e)}, status=403)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"success": False, "code": "missing", "error": "Preview no longer exists"}, status=404)
|
||||
except OSError as e:
|
||||
return web.json_response({"success": False, "error": f"Failed to save: {e}"}, status=500)
|
||||
return web.json_response({"success": True, "audio": item})
|
||||
|
||||
|
||||
async def api_delete_audio_gallery(request):
|
||||
"""POST /anomalous/delete_audio_gallery - Delete one generated audio file."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
|
||||
|
||||
filename = str(data.get("filename", "")).strip()
|
||||
subfolder = str(data.get("subfolder", "")).strip().strip("/\\")
|
||||
if not filename or os.path.basename(filename) != filename:
|
||||
return web.json_response({"success": False, "error": "Invalid filename"}, status=400)
|
||||
if os.path.splitext(filename)[1].lower() not in AUDIO_EXTENSIONS:
|
||||
return web.json_response({"success": False, "error": "Only audio files can be deleted here"}, status=415)
|
||||
|
||||
try:
|
||||
target = resolve_within(folder_paths.get_output_directory(), subfolder, filename)
|
||||
except ValueError:
|
||||
return web.json_response({"success": False, "error": "Forbidden path"}, status=403)
|
||||
|
||||
if not os.path.isfile(target):
|
||||
return web.json_response({"success": False, "error": "File not found"}, status=404)
|
||||
try:
|
||||
await asyncio.to_thread(move_to_trash, target)
|
||||
except OSError as e:
|
||||
return web.json_response({"success": False, "error": trash_failure(e)}, status=409)
|
||||
return web.json_response({"success": True})
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Read ComfyUI generation info embedded in saved audio (FLAC and Ogg/Opus comments).
|
||||
|
||||
ComfyUI's audio save/preview nodes store the API prompt as a `prompt` comment.
|
||||
Only the fields the audio gallery shows are extracted; nothing is written.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
MAX_TAG_BYTES = 16 * 1024 * 1024
|
||||
MAX_OGG_SCAN_BYTES = 32 * 1024 * 1024
|
||||
SPEECH_INPUTS = ("speech", "text", "gen_text")
|
||||
# "character": Anomalous_TTS (GPT-SoVITS) names the voice by character instead of a file.
|
||||
SAMPLE_INPUTS = ("sample", "ref_audio", "audio_prompt", "character")
|
||||
|
||||
|
||||
def _parse_vorbis_comment(data, offset=0):
|
||||
"""Parse a Vorbis comment block into {lowercase key: value}."""
|
||||
tags = {}
|
||||
try:
|
||||
vendor_len = int.from_bytes(data[offset:offset + 4], "little")
|
||||
pos = offset + 4 + vendor_len
|
||||
count = int.from_bytes(data[pos:pos + 4], "little")
|
||||
pos += 4
|
||||
for _ in range(min(count, 1024)):
|
||||
length = int.from_bytes(data[pos:pos + 4], "little")
|
||||
pos += 4
|
||||
entry = data[pos:pos + length].decode("utf-8", "replace")
|
||||
pos += length
|
||||
key, sep, value = entry.partition("=")
|
||||
if sep:
|
||||
tags.setdefault(key.lower(), value)
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
return tags
|
||||
|
||||
|
||||
def _flac_tags(handle):
|
||||
if handle.read(4) != b"fLaC":
|
||||
return {}
|
||||
while True:
|
||||
header = handle.read(4)
|
||||
if len(header) < 4:
|
||||
return {}
|
||||
is_last = header[0] & 0x80
|
||||
block_type = header[0] & 0x7F
|
||||
length = int.from_bytes(header[1:4], "big")
|
||||
if block_type == 4:
|
||||
if length > MAX_TAG_BYTES:
|
||||
return {}
|
||||
return _parse_vorbis_comment(handle.read(length))
|
||||
handle.seek(length, os.SEEK_CUR)
|
||||
if is_last:
|
||||
return {}
|
||||
|
||||
|
||||
def _ogg_packets(handle, wanted=2):
|
||||
"""Yield the first `wanted` logical packets of an Ogg stream."""
|
||||
packet = b""
|
||||
scanned = 0
|
||||
found = 0
|
||||
while found < wanted and scanned < MAX_OGG_SCAN_BYTES:
|
||||
header = handle.read(27)
|
||||
if len(header) < 27 or header[:4] != b"OggS":
|
||||
return
|
||||
segments = header[26]
|
||||
lacing = handle.read(segments)
|
||||
scanned += 27 + segments
|
||||
for size in lacing:
|
||||
packet += handle.read(size)
|
||||
scanned += size
|
||||
if size < 255:
|
||||
yield packet
|
||||
packet = b""
|
||||
found += 1
|
||||
if found >= wanted:
|
||||
return
|
||||
if len(packet) > MAX_TAG_BYTES:
|
||||
return
|
||||
|
||||
|
||||
def _ogg_tags(handle):
|
||||
packets = list(_ogg_packets(handle, wanted=2))
|
||||
if len(packets) < 2:
|
||||
return {}
|
||||
comment = packets[1]
|
||||
if comment.startswith(b"OpusTags"):
|
||||
return _parse_vorbis_comment(comment, 8)
|
||||
if comment.startswith(b"\x03vorbis"):
|
||||
return _parse_vorbis_comment(comment, 7)
|
||||
return {}
|
||||
|
||||
|
||||
def read_audio_tags(path):
|
||||
"""Return {lowercase key: value} comments for FLAC/Ogg/Opus files, else {}."""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
if ext == ".flac":
|
||||
return _flac_tags(handle)
|
||||
if ext in (".ogg", ".opus"):
|
||||
return _ogg_tags(handle)
|
||||
except OSError:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def generation_info(path):
|
||||
"""Speech text, sample voice and seed of the TTS node that produced this file, if recorded."""
|
||||
raw = read_audio_tags(path).get("prompt")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
prompt = json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(prompt, dict):
|
||||
return None
|
||||
|
||||
nodes = [node for node in prompt.values() if isinstance(node, dict) and isinstance(node.get("inputs"), dict)]
|
||||
# Prefer TTS nodes; otherwise any node with a speech-like text input.
|
||||
nodes.sort(key=lambda node: "tts" not in str(node.get("class_type", "")).lower())
|
||||
for node in nodes:
|
||||
inputs = node["inputs"]
|
||||
speech = next((inputs[key] for key in SPEECH_INPUTS if isinstance(inputs.get(key), str) and inputs[key].strip()), None)
|
||||
if speech is None:
|
||||
continue
|
||||
sample = next((inputs[key] for key in SAMPLE_INPUTS if isinstance(inputs.get(key), str)), None)
|
||||
seed = inputs.get("seed")
|
||||
return {
|
||||
"node_type": str(node.get("class_type") or ""),
|
||||
"speech": speech[:4000],
|
||||
"sample": sample.replace("\\", "/") if sample else None,
|
||||
"seed": seed if isinstance(seed, (int, float)) else None,
|
||||
}
|
||||
return None
|
||||
@@ -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,132 @@
|
||||
"""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 .media_routes import CARD_THUMBNAIL_STATIC_EXTENSIONS, _build_card_thumbnail
|
||||
from .path_utils import require_filename, resolve_within
|
||||
from .trash import move_to_trash, trash_failure
|
||||
|
||||
|
||||
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_output_thumbnail(request):
|
||||
"""GET /anomalous/output_thumbnail?filename&subfolder - the gallery grid's 512 px WebP of an
|
||||
output image (cached like model cards); the original stays at ComfyUI's /view."""
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
try:
|
||||
filename = require_filename(request.query.get('filename'))
|
||||
file_path = resolve_within(resolve_within(output_dir, request.query.get('subfolder', '')), filename)
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
|
||||
if not os.path.isfile(file_path):
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
served = file_path
|
||||
if os.path.splitext(file_path)[1].lower() in CARD_THUMBNAIL_STATIC_EXTENSIONS:
|
||||
served = await asyncio.to_thread(_build_card_thumbnail, file_path)
|
||||
# Revalidate (cheap 304s): an output file can be replaced under the same name.
|
||||
return web.FileResponse(served, headers={'Cache-Control': 'no-cache'})
|
||||
|
||||
|
||||
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):
|
||||
try:
|
||||
await asyncio.to_thread(move_to_trash, file_path)
|
||||
except OSError as e:
|
||||
return web.json_response({"status": "error", "message": trash_failure(e)}, status=409)
|
||||
_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,43 @@
|
||||
"""What each output image was kept as (GET /anomalous/kept_images), for the gallery's star:
|
||||
the Workflow Recipe whose source image it is, the combo made from it (a notebook file with
|
||||
`source_image`), the saved prompt taken from it (a prompt plan material whose source names
|
||||
the image). Each is {filename, name}, or null when the image was not kept as that."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .material_store import _list_materials, get_materials_dir
|
||||
from .notebooks import _list_notebooks
|
||||
from .recipe_store import _list_recipes, get_recipes_dir
|
||||
|
||||
KINDS = ("recipe", "combo", "prompt")
|
||||
|
||||
|
||||
def _kept_images():
|
||||
kept = {}
|
||||
|
||||
def mark(image, kind, filename, name):
|
||||
if not isinstance(image, dict) or not isinstance(image.get("filename"), str) or not image["filename"]:
|
||||
return
|
||||
subfolder = image.get("subfolder") if isinstance(image.get("subfolder"), str) else ""
|
||||
entry = kept.setdefault((subfolder, image["filename"]), dict.fromkeys(KINDS))
|
||||
if entry[kind] is None:
|
||||
entry[kind] = {"filename": filename, "name": str(name or "")}
|
||||
|
||||
for recipe in _list_recipes(get_recipes_dir()):
|
||||
mark(recipe["data"].get("source_image"), "recipe", recipe["filename"], recipe["data"].get("name"))
|
||||
for note in _list_notebooks():
|
||||
mark(note["data"].get("source_image"), "combo", note["filename"], note["name"])
|
||||
for material in _list_materials(get_materials_dir()):
|
||||
if material.get("kind") == "prompt_plan":
|
||||
mark(material.get("source_image"), "prompt", material["filename"], material.get("name"))
|
||||
return [{"subfolder": subfolder, "filename": filename, **entry} for (subfolder, filename), entry in kept.items()]
|
||||
|
||||
|
||||
async def api_kept_images(request):
|
||||
try:
|
||||
images = await asyncio.to_thread(_kept_images)
|
||||
except (OSError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Could not list kept images"}, status=500)
|
||||
return web.json_response({"status": "success", "images": images})
|
||||
@@ -0,0 +1,82 @@
|
||||
"""What only this computer may do. ComfyUI started with --listen lets other devices on the
|
||||
network open the page: they may look (lists, searches, pictures, covers), but every change
|
||||
under /anomalous/ (writing, moving and deleting files, downloads, scans, translations, backups,
|
||||
version switches, saved settings) answers only requests from the computer ComfyUI runs on
|
||||
(AGENTS.md 安全底线, rule 4). One middleware decides it for all routes: a request other than
|
||||
GET is local-only unless it is a read sent as POST (OPEN_POSTS); a GET is open unless it is not
|
||||
a plain read (LOCAL_GETS). A request is local when it comes from a loopback address, through no
|
||||
proxy, naming a loopback host; checking the Host also keeps out a page that reached this server
|
||||
under another name (DNS rebinding)."""
|
||||
|
||||
import ipaddress
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
PREFIX = "/anomalous/"
|
||||
PROXY_HEADERS = ("Forwarded", "X-Forwarded-For", "X-Real-IP", "X-Forwarded-Host")
|
||||
LOCAL_NAMES = ("localhost",)
|
||||
# Reads sent as POST (their lists are long): open to other devices too. They write nothing.
|
||||
OPEN_POSTS = frozenset({
|
||||
"/anomalous/resolve_paths_to_previews",
|
||||
"/anomalous/resolve_hash_batch",
|
||||
"/anomalous/inspect_image_material",
|
||||
})
|
||||
# GETs that are more than a read: a backup of all your data, a call to GitHub, your folders.
|
||||
LOCAL_GETS = frozenset({
|
||||
"/anomalous/backup/file",
|
||||
"/anomalous/version/releases",
|
||||
"/anomalous/import/folders",
|
||||
})
|
||||
# Routes that answer other devices themselves (MCP replies in JSON-RPC).
|
||||
OWN_CHECK = frozenset({"/anomalous/mcp"})
|
||||
READS = ("GET", "HEAD", "OPTIONS")
|
||||
MESSAGE = ("只能在运行 ComfyUI 的电脑上操作,别的设备只能浏览。"
|
||||
" Only the computer ComfyUI runs on can do this; other devices can only look.")
|
||||
|
||||
|
||||
def loopback_host(name):
|
||||
if not name:
|
||||
return False
|
||||
name = name.strip("[]").lower()
|
||||
if name in LOCAL_NAMES:
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(name).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def is_local(request):
|
||||
"""The request comes from this computer, directly, and names a loopback host."""
|
||||
if not loopback_host(request.remote or ""):
|
||||
return False
|
||||
if any(header in request.headers for header in PROXY_HEADERS):
|
||||
return False
|
||||
return loopback_host(urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname)
|
||||
|
||||
|
||||
def needs_local(method, path):
|
||||
"""Whether a request to `path` with `method` must come from this computer."""
|
||||
if not path.startswith(PREFIX) or path in OWN_CHECK:
|
||||
return False
|
||||
if method.upper() in READS:
|
||||
return path in LOCAL_GETS
|
||||
return path not in OPEN_POSTS
|
||||
|
||||
|
||||
def refusal():
|
||||
return web.json_response({"status": "error", "success": False, "error": "local_only", "code": "local_only",
|
||||
"message": MESSAGE}, status=403)
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def local_only_middleware(request, handler):
|
||||
if needs_local(request.method, request.path) and not is_local(request):
|
||||
return refusal()
|
||||
return await handler(request)
|
||||
|
||||
|
||||
async def api_access(request):
|
||||
"""GET /anomalous/access - whether this page may change things: {"local": bool}."""
|
||||
return web.json_response({"local": is_local(request)})
|
||||
@@ -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,517 @@
|
||||
"""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 _summary_source_image(source):
|
||||
"""The output image a material was taken from, as {filename, subfolder}, or None."""
|
||||
image = source.get("image")
|
||||
if not isinstance(image, dict) or not isinstance(image.get("filename"), str) or not image["filename"]:
|
||||
return None
|
||||
return {"filename": image["filename"], "subfolder": image.get("subfolder") if isinstance(image.get("subfolder"), str) else ""}
|
||||
|
||||
|
||||
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 ""),
|
||||
"moved_to_recipe": str(material.get("moved_to_recipe") or ""),
|
||||
"source_image": _summary_source_image(source),
|
||||
}
|
||||
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,264 @@
|
||||
"""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 .recipe_constants import MAX_RECIPE_BYTES
|
||||
from .recipe_store import _read_recipe, get_recipes_dir
|
||||
from .trash import move_to_trash
|
||||
from .utils import atomic_write_json as _atomic_write_json, require_filename, resolve_within
|
||||
|
||||
|
||||
_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 _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", "node_parameter_selection",
|
||||
"prompt_note_bundle", "prompt_text", "prompt_plan"
|
||||
):
|
||||
raise ValueError("Invalid material filter")
|
||||
# Whole workflows live with the recipes: "all" is the pieces, and one moved there is not listed.
|
||||
materials = [material for material in materials
|
||||
if not material.get("moved_to_recipe")
|
||||
and (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 (_material_category(material) == category if category != "all" else _material_category(material) != "workflow")
|
||||
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 _mark_material_moved(materials_dir, filename, recipe_filename):
|
||||
"""Notes that a kept workflow now lives as recipe `recipe_filename`; the material file stays."""
|
||||
with _material_write_lock:
|
||||
path = resolve_within(materials_dir, filename)
|
||||
material = _read_material(path)
|
||||
material["moved_to_recipe"] = recipe_filename
|
||||
_atomic_write_json(path, material)
|
||||
|
||||
|
||||
def _delete_material(materials_dir, filename, path):
|
||||
with _material_write_lock:
|
||||
_read_material(path)
|
||||
move_to_trash(path, _material_assets_dir(materials_dir, filename))
|
||||
@@ -0,0 +1,451 @@
|
||||
"""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,
|
||||
_prompt_excerpt,
|
||||
_prompt_groups_from_roles,
|
||||
_prompt_roles_for_workflow,
|
||||
_workflow_hashes_for_blocks,
|
||||
)
|
||||
from .notebooks import MAX_NOTEBOOK_BYTES
|
||||
from .recipe_schema import _build_model_references, _normalise_source_image
|
||||
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_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 _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_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")
|
||||
# The output image the prompts were taken from, so the gallery can show it was kept.
|
||||
source_image = _normalise_source_image(payload.get("source_image"))
|
||||
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,
|
||||
**({"image": source_image} if source_image else {})},
|
||||
"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_mark_material_moved(request):
|
||||
"""POST {filename, recipe}: the kept workflow `filename` was saved as recipe `recipe`."""
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
recipe = require_filename(payload.get("recipe", ""))
|
||||
if not filename.endswith(".json") or not recipe.endswith(".json"):
|
||||
raise ValueError("Invalid material")
|
||||
await asyncio.to_thread(_store._mark_material_moved, get_materials_dir(), filename, recipe)
|
||||
return web.json_response({"status": "success"})
|
||||
except (AttributeError, TypeError, 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 update material"}, 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):
|
||||
# A whole workflow moved to a recipe is offered through the recipe's own sets.
|
||||
if node_type not in summary["node_types"] or summary.get("moved_to_recipe"):
|
||||
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,427 @@
|
||||
"""Model cover, thumbnail-cache, and generated-image lookup routes.
|
||||
|
||||
Card images are 512 px WebP copies of the covers (a video cover gets one of its first
|
||||
frame, the poster), kept in the user folder so they survive ComfyUI restarts (ComfyUI
|
||||
empties its temp folder on every start). Listing a folder queues the covers not made yet
|
||||
for one background worker, so scrolling and the next visit find them ready.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
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'}
|
||||
CARD_POSTER_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov'}
|
||||
CARD_WARM_QUEUE_LIMIT = 512
|
||||
_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():
|
||||
"""<user folder>/anomalous/cache/card_thumbnails; the system temp folder if there is none."""
|
||||
try:
|
||||
user_dir = folder_paths.get_user_directory()
|
||||
except Exception:
|
||||
user_dir = None
|
||||
if isinstance(user_dir, (str, os.PathLike)):
|
||||
cache_dir = os.path.join(user_dir, "anomalous", "cache", "card_thumbnails")
|
||||
else:
|
||||
cache_dir = os.path.join(tempfile.gettempdir(), "anomalous_model_browser", "card_thumbnails")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
|
||||
def thumbnail_cache_usage():
|
||||
"""(file count, bytes) of the card image cache, for the settings page."""
|
||||
count = size = 0
|
||||
try:
|
||||
with os.scandir(_thumbnail_cache_directory()) as iterator:
|
||||
for entry in iterator:
|
||||
if entry.is_file() and entry.name.endswith('.webp'):
|
||||
count += 1
|
||||
try:
|
||||
size += entry.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return count, size
|
||||
|
||||
|
||||
def clear_thumbnail_cache():
|
||||
"""Delete the derived card images (never a cover); they are made again when needed."""
|
||||
removed = 0
|
||||
try:
|
||||
with os.scandir(_thumbnail_cache_directory()) as iterator:
|
||||
for entry in iterator:
|
||||
if entry.is_file() and entry.name.endswith('.webp'):
|
||||
try:
|
||||
os.remove(entry.path)
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
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 _cached_card_path(source_path, kind=''):
|
||||
"""Where the card image of `source_path` is cached: keyed by the file's identity and edge."""
|
||||
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),
|
||||
*((kind,) if kind else ()),
|
||||
))
|
||||
digest = hashlib.sha256(cache_key.encode('utf-8', errors='surrogatepass')).hexdigest()
|
||||
cache_dir = _thumbnail_cache_directory()
|
||||
return cache_dir, os.path.join(cache_dir, f"{digest}.webp")
|
||||
|
||||
|
||||
def _reuse_cached(cache_dir, cached_path):
|
||||
if not os.path.isfile(cached_path):
|
||||
return False
|
||||
try:
|
||||
os.utime(cached_path, None)
|
||||
except OSError:
|
||||
pass
|
||||
_prune_thumbnail_cache(cache_dir)
|
||||
return True
|
||||
|
||||
|
||||
def _save_card_image(image, cache_dir, cached_path):
|
||||
"""Shrink a PIL image to the card edge and store it as WebP (atomically)."""
|
||||
from PIL import Image
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
cache_dir, cached_path = _cached_card_path(source_path)
|
||||
if _reuse_cached(cache_dir, cached_path):
|
||||
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
|
||||
_save_card_image(image, cache_dir, cached_path)
|
||||
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)
|
||||
|
||||
|
||||
def _build_video_poster_impl(source_path):
|
||||
"""A cached 512 px WebP of a video cover's first frame, or None (no decoder, bad file)."""
|
||||
try:
|
||||
cache_dir, cached_path = _cached_card_path(source_path, 'poster')
|
||||
if _reuse_cached(cache_dir, cached_path):
|
||||
return cached_path
|
||||
import av # PyAV ships with ComfyUI for its video nodes
|
||||
|
||||
with av.open(source_path) as container:
|
||||
stream = next((s for s in container.streams if s.type == 'video'), None)
|
||||
if stream is None:
|
||||
return None
|
||||
stream.thread_type = 'AUTO'
|
||||
frame = next(container.decode(stream), None)
|
||||
if frame is None:
|
||||
return None
|
||||
image = frame.to_image()
|
||||
_save_card_image(image, cache_dir, cached_path)
|
||||
return cached_path
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_video_poster(source_path):
|
||||
with _thumbnail_generation_slots:
|
||||
return _build_video_poster_impl(source_path)
|
||||
|
||||
|
||||
# ---------- background warm-up of card images ----------
|
||||
|
||||
_warm_queue = collections.deque()
|
||||
_warm_pending = set()
|
||||
_warm_lock = threading.Lock()
|
||||
_warm_thread = None
|
||||
|
||||
|
||||
def _warm_worker():
|
||||
"""One thread, one decode at a time, a short rest between: never more than half the slots."""
|
||||
global _warm_thread
|
||||
while True:
|
||||
with _warm_lock:
|
||||
if not _warm_queue:
|
||||
_warm_thread = None
|
||||
return
|
||||
path = _warm_queue.popleft()
|
||||
_warm_pending.discard(path)
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in CARD_POSTER_VIDEO_EXTENSIONS:
|
||||
_build_video_poster(path)
|
||||
else:
|
||||
_build_card_thumbnail(path)
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
def queue_card_images(paths):
|
||||
"""Make the card images of these covers in the background (bounded, deduplicated)."""
|
||||
global _warm_thread
|
||||
with _warm_lock:
|
||||
for path in paths:
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext not in CARD_THUMBNAIL_STATIC_EXTENSIONS and ext not in CARD_POSTER_VIDEO_EXTENSIONS:
|
||||
continue
|
||||
if path in _warm_pending or len(_warm_queue) >= CARD_WARM_QUEUE_LIMIT:
|
||||
continue
|
||||
_warm_pending.add(path)
|
||||
_warm_queue.append(path)
|
||||
if _warm_queue and _warm_thread is None:
|
||||
_warm_thread = threading.Thread(target=_warm_worker, name="anomalous-card-images", daemon=True)
|
||||
_warm_thread.start()
|
||||
|
||||
|
||||
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
|
||||
variant = request.query.get('variant')
|
||||
if 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'
|
||||
elif variant == 'poster':
|
||||
# The card's still image of a video cover; nothing (404) when it cannot be read.
|
||||
poster = await asyncio.to_thread(_build_video_poster, file_path) if ext in CARD_POSTER_VIDEO_EXTENSIONS else None
|
||||
if not poster:
|
||||
return web.Response(status=404, text='No poster')
|
||||
served_path, content_type = poster, '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_card_cache(request):
|
||||
"""GET /anomalous/card_cache - how many card images are cached and their size."""
|
||||
count, size = await asyncio.to_thread(thumbnail_cache_usage)
|
||||
return web.json_response({"count": count, "bytes": size, "limit": CARD_THUMBNAIL_CACHE_LIMIT})
|
||||
|
||||
|
||||
async def api_clear_card_cache(request):
|
||||
"""POST /anomalous/card_cache/clear - delete the derived card images (made again when shown)."""
|
||||
removed = await asyncio.to_thread(clear_thumbnail_cache)
|
||||
return web.json_response({"status": "ok", "removed": removed})
|
||||
|
||||
|
||||
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})
|
||||
@@ -4,109 +4,88 @@ import json
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
import copy
|
||||
from functools import lru_cache
|
||||
from aiohttp import web
|
||||
import asyncio
|
||||
import copy
|
||||
from functools import lru_cache
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
try:
|
||||
from ..model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info, unmatched_reason
|
||||
except ImportError:
|
||||
from model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info, unmatched_reason
|
||||
|
||||
# Fields the model editor sets: <model>.anomalous.json key -> metadata key.
|
||||
USER_FIELDS = {"custom_name": "custom_name", "custom_notes": "custom_notes", "source_url": "source_url"}
|
||||
|
||||
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
|
||||
|
||||
def _read_safetensors_hash(file_path):
|
||||
"""Extract hash from safetensors header metadata (O(1) fast read, no full-file SHA256)."""
|
||||
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:
|
||||
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
|
||||
|
||||
if 'modelspec.hash.sha256' in metadata:
|
||||
return metadata['modelspec.hash.sha256']
|
||||
if 'modelspec.hash.blake3' in metadata:
|
||||
return metadata['modelspec.hash.blake3']
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _extract_safetensors_hash_cached(file_path, signature):
|
||||
return _read_safetensors_hash(file_path)
|
||||
|
||||
|
||||
def _extract_safetensors_hash(file_path):
|
||||
normalized_path = os.path.realpath(file_path)
|
||||
return _extract_safetensors_hash_cached(normalized_path, _file_signature(normalized_path))
|
||||
|
||||
|
||||
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 _read_metadata(file_path):
|
||||
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),
|
||||
@@ -115,9 +94,17 @@ def _read_metadata(file_path):
|
||||
"trainedWords": [],
|
||||
"baseModel": "",
|
||||
"civitai_url": "",
|
||||
"hash": "",
|
||||
"custom_name": "",
|
||||
"custom_notes": ""
|
||||
"source_url": "",
|
||||
"hash": "",
|
||||
"hash_algorithm": "sha256",
|
||||
"hash_scope": "file",
|
||||
"hash_source": "",
|
||||
"custom_name": "",
|
||||
"custom_notes": "",
|
||||
# "civitai" (matched), "local" (only what the file tells: no match, or offline) or "" (not scanned)
|
||||
"info_source": "",
|
||||
"unmatched_reason": "", # for "local": not_found | network | offline | "" (scanned before this was kept)
|
||||
"user_fields": [],
|
||||
}
|
||||
|
||||
info_files = [f"{base_path}.info", f"{base_path}.civitai.info"]
|
||||
@@ -130,7 +117,8 @@ def _read_metadata(file_path):
|
||||
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(' -')
|
||||
# A model and version of one name (what the file alone tells) read once.
|
||||
name = f"{model_name} - {name}" if model_name and name and model_name != name else model_name or name
|
||||
|
||||
description = data.get("description", "") or ""
|
||||
if not description and "model" in data and isinstance(data["model"], dict):
|
||||
@@ -139,10 +127,10 @@ def _read_metadata(file_path):
|
||||
trained_words = data.get("trainedWords", [])
|
||||
base_model = data.get("baseModel", "")
|
||||
|
||||
model_id = data.get("modelId", "")
|
||||
version_id = data.get("id", "")
|
||||
model_id = _positive_civitai_id(data.get("modelId"))
|
||||
version_id = _positive_civitai_id(data.get("id"))
|
||||
civitai_url = ""
|
||||
if model_id:
|
||||
if model_id is not None:
|
||||
# Handle Civitai's new mature content policy
|
||||
nsfw_level = data.get("nsfwLevel", 1)
|
||||
is_nsfw = False
|
||||
@@ -151,79 +139,82 @@ def _read_metadata(file_path):
|
||||
|
||||
domain = "civitai.red" if (nsfw_level > 1 or is_nsfw) else "civitai.com"
|
||||
civitai_url = f"https://{domain}/models/{model_id}"
|
||||
if version_id:
|
||||
if version_id is not None:
|
||||
civitai_url += f"?modelVersionId={version_id}"
|
||||
|
||||
|
||||
selected_file = _select_info_file(data, file_path)
|
||||
hash_val = ""
|
||||
if selected_file:
|
||||
hashes = selected_file.get("hashes", {})
|
||||
if isinstance(hashes, dict):
|
||||
hash_val = str(hashes.get("SHA256", ""))
|
||||
|
||||
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: metadata["model_id"] = model_id
|
||||
if version_id: 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"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: if no hash found from .info files, try extracting from safetensors header directly
|
||||
if not metadata["hash"] and file_path.endswith('.safetensors'):
|
||||
header_hash = _extract_safetensors_hash(file_path)
|
||||
if header_hash:
|
||||
metadata["hash"] = header_hash
|
||||
|
||||
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()
|
||||
_extract_safetensors_hash_cached.cache_clear()
|
||||
|
||||
|
||||
def get_metadata_cache_info():
|
||||
return _get_metadata_cached.cache_info()
|
||||
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
|
||||
|
||||
scan = sidecar_info(base_path)
|
||||
if scan is not None:
|
||||
metadata["info_source"] = "local" if is_unmatched(scan) else "civitai"
|
||||
metadata["unmatched_reason"] = unmatched_reason(scan)
|
||||
# The user's own layer comes last and wins, also when a field was cleared on purpose ("").
|
||||
user = read_json(base_path + USER_INFO_SUFFIX) or {}
|
||||
for key, field in USER_FIELDS.items():
|
||||
if isinstance(user.get(key), str):
|
||||
metadata[field] = user[key]
|
||||
metadata["user_fields"].append(field)
|
||||
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"),
|
||||
_file_signature(base_path + USER_INFO_SUFFIX),
|
||||
)
|
||||
|
||||
|
||||
@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,691 @@
|
||||
"""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, PREVIEW_SUFFIXES, is_model_file
|
||||
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 is_model_file(name)),
|
||||
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(is_model_file(f) 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 is_model_file(f))
|
||||
}
|
||||
|
||||
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(is_model_file(f) 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 is_model_file(f))
|
||||
}
|
||||
|
||||
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 is_model_file(filename):
|
||||
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 is_model_file(filename):
|
||||
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 is_model_file(candidate):
|
||||
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 is_model_file(filename):
|
||||
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):
|
||||
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 is_model_file(f):
|
||||
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 is_model_file(f):
|
||||
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 is_model_file(f):
|
||||
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,23 @@
|
||||
"""Shared model catalog, media, and recovery constants."""
|
||||
|
||||
MEDIA_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi')
|
||||
# Model files the browser lists, counts and looks up: GGUF and .pth too (quantized
|
||||
# diffusion models, upscalers). Compared without case: see is_model_file().
|
||||
MODEL_EXTENSIONS = ('.safetensors', '.ckpt', '.pt', '.pth', '.bin', '.sft', '.gguf')
|
||||
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', '.anomalous.json', '.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', 'upscale_models',
|
||||
)
|
||||
|
||||
|
||||
def is_model_file(name):
|
||||
"""Whether a file name is a model file (MODEL_EXTENSIONS, any case)."""
|
||||
return name.lower().endswith(MODEL_EXTENSIONS)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Shows a model file in this computer's file manager: Explorer or Finder with the file
|
||||
selected, the folder on Linux. The request names the model the way the browser lists it
|
||||
(type, folder index, subfolder, file name), never a path, and local_guard lets only this
|
||||
computer ask (it is a POST)."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
|
||||
|
||||
def _explorer_windows():
|
||||
"""Handles of the open Explorer folder windows (window class CabinetWClass)."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
found = []
|
||||
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
||||
|
||||
def collect(hwnd, _):
|
||||
name = ctypes.create_unicode_buffer(64)
|
||||
ctypes.windll.user32.GetClassNameW(hwnd, name, 64)
|
||||
if name.value == "CabinetWClass":
|
||||
found.append(hwnd)
|
||||
return True
|
||||
|
||||
ctypes.windll.user32.EnumWindows(callback_type(collect), 0)
|
||||
return found
|
||||
|
||||
|
||||
def _window_title(hwnd):
|
||||
import ctypes
|
||||
|
||||
title = ctypes.create_unicode_buffer(512)
|
||||
ctypes.windll.user32.GetWindowTextW(hwnd, title, 512)
|
||||
return title.value
|
||||
|
||||
|
||||
def _to_front(hwnd):
|
||||
"""Windows keeps a background program (ComfyUI here) from raising a window; borrowing the
|
||||
foreground window's input queue for a moment lets it."""
|
||||
import ctypes
|
||||
|
||||
user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32
|
||||
foreground = user32.GetForegroundWindow()
|
||||
theirs = user32.GetWindowThreadProcessId(foreground, None)
|
||||
mine = kernel32.GetCurrentThreadId()
|
||||
attached = theirs and theirs != mine and user32.AttachThreadInput(mine, theirs, True)
|
||||
try:
|
||||
user32.ShowWindow(hwnd, 9 if user32.IsIconic(hwnd) else 5) # SW_RESTORE / SW_SHOW
|
||||
user32.BringWindowToTop(hwnd)
|
||||
user32.SetForegroundWindow(hwnd)
|
||||
finally:
|
||||
if attached:
|
||||
user32.AttachThreadInput(mine, theirs, False)
|
||||
|
||||
|
||||
def _raise_explorer(folder, before):
|
||||
"""Waits up to 3 s for the window showing `folder` (a new one, or the open one Explorer
|
||||
reused) and brings it to the front."""
|
||||
names = (os.path.basename(folder).lower(), os.path.normcase(folder).lower())
|
||||
|
||||
def shows_folder(hwnd):
|
||||
# The folder's name (or full path), then " - File Explorer" in the system's language.
|
||||
title = _window_title(hwnd).lower()
|
||||
return any(title == name or title.startswith(name + " - ") for name in names)
|
||||
|
||||
for _ in range(30):
|
||||
time.sleep(0.1)
|
||||
windows = _explorer_windows()
|
||||
fresh = [hwnd for hwnd in windows if hwnd not in before]
|
||||
match = fresh or [hwnd for hwnd in windows if shows_folder(hwnd)]
|
||||
if match:
|
||||
_to_front(match[0])
|
||||
return
|
||||
|
||||
|
||||
def _reveal(path):
|
||||
if sys.platform == "win32":
|
||||
before = set(_explorer_windows())
|
||||
subprocess.Popen(f'explorer /select,"{path}"')
|
||||
threading.Thread(target=_raise_explorer, args=(os.path.dirname(path), before), daemon=True).start()
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", "-R", path])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", os.path.dirname(path)])
|
||||
|
||||
|
||||
async def api_open_model_folder(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 = require_filename(data.get("filename", ""))
|
||||
_, folder = resolve_folder_subdir(data.get("type", ""), int(data.get("path_idx", 0)), data.get("subfolder", "/"))
|
||||
path = resolve_within(folder, filename)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
|
||||
if not os.path.isfile(path):
|
||||
return web.json_response({"status": "error", "message": "Model file not found"}, status=404)
|
||||
try:
|
||||
_reveal(path)
|
||||
except OSError as error:
|
||||
return web.json_response({"status": "error", "message": str(error)}, status=500)
|
||||
return web.json_response({"status": "success"})
|
||||
@@ -0,0 +1,143 @@
|
||||
"""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 .trash import move_to_trash
|
||||
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}")
|
||||
|
||||
# Only one cover is active: the others go to the Recycle Bin (a failure keeps them).
|
||||
older = [os.path.join(target_dir, f"{base_name}{ext}") for ext in PREVIEW_SUFFIXES]
|
||||
try:
|
||||
move_to_trash(*[path for path in older if path != dest_path])
|
||||
except OSError as e:
|
||||
print(f"[Anomalous Browser] Old cover kept: {e}")
|
||||
|
||||
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,258 @@
|
||||
"""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 .activity_log import ACTIVITY_DETAIL
|
||||
from .metadata import get_metadata
|
||||
from .model_catalog import _resolve_paths_to_model_info_sync
|
||||
from .trash import move_to_trash, trash_failure
|
||||
from .utils import atomic_write_json
|
||||
try:
|
||||
from ..model_identity import USER_INFO_SUFFIX, read_json
|
||||
except ImportError:
|
||||
from model_identity import USER_INFO_SUFFIX, read_json
|
||||
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)
|
||||
move_to_trash(*[path for path in preview_paths if path != restored_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:
|
||||
move_to_trash(*preview_paths)
|
||||
except Exception as exc:
|
||||
return False, 'restore_failed', trash_failure(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"})
|
||||
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
# 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 if base_name + model_ext != filename
|
||||
)
|
||||
sidecars = [] if shared_stem_in_use else [
|
||||
os.path.join(target_dir, base_name + suffix) for suffix in SIDECAR_SUFFIXES
|
||||
]
|
||||
# The model and its covers and info go to the Recycle Bin together, or nothing moves.
|
||||
try:
|
||||
moved = move_to_trash(model_path, *sidecars)
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": trash_failure(e)})
|
||||
deleted_files = [os.path.basename(path) for path in moved]
|
||||
request[ACTIVITY_DETAIL] = {"files": deleted_files}
|
||||
|
||||
# 前端期待的成功状态是 "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]
|
||||
# The user's edits have a file of their own (<model>.anomalous.json): a scan never
|
||||
# writes it, and other tools that rewrite .civitai.info cannot lose them.
|
||||
user_file = base_name + USER_INFO_SUFFIX
|
||||
if os.path.exists(user_file) and read_json(user_file) is None:
|
||||
return web.json_response({"status": "error", "message": f"{os.path.basename(user_file)} is unreadable; nothing was changed."})
|
||||
user_data = read_json(user_file) or {"format": 1}
|
||||
before = get_metadata(file_path) # what was shown, for the activity log
|
||||
if 'custom_name' in data:
|
||||
user_data["custom_name"] = str(custom_name)
|
||||
if 'custom_notes' in data:
|
||||
user_data["custom_notes"] = str(custom_notes)
|
||||
if custom_source_url is not None:
|
||||
user_data["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)
|
||||
|
||||
atomic_write_json(user_file, user_data)
|
||||
|
||||
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."})
|
||||
|
||||
sent = [key for key in ("custom_name", "custom_notes") if key in data]
|
||||
sent += ["source_url"] if custom_source_url is not None else []
|
||||
fields = [{"field": key, "before": before.get(key) or "", "after": user_data[key]}
|
||||
for key in sent if user_data[key] != (before.get(key) or "")]
|
||||
if new_filename != filename:
|
||||
fields.append({"field": "filename", "before": filename, "after": new_filename})
|
||||
request[ACTIVITY_DETAIL] = {"fields": fields}
|
||||
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,332 @@
|
||||
"""Cryptographic and size-based missing-model resolution."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
try:
|
||||
from ..model_identity import computed_file_identity, file_sha256, infer_base_model_from_header, normalise_sha256
|
||||
except ImportError:
|
||||
from model_identity import computed_file_identity, file_sha256, infer_base_model_from_header, 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:
|
||||
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:
|
||||
for candidate in unhashed_size_matches:
|
||||
try:
|
||||
computed_hash = file_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,54 @@
|
||||
"""One model type as a single list: a models folder and all its subfolders (the models page's type chips).
|
||||
|
||||
Listing a type also queues its covers' card images for the background (media_routes).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .model_catalog import _collect_folder_models
|
||||
from .media_routes import queue_card_images
|
||||
|
||||
|
||||
def _collect_type_models(folder_type, path_idx):
|
||||
"""Every model under one registered models folder, folder by folder in name order."""
|
||||
try:
|
||||
base_dir = folder_paths.get_folder_paths(folder_type)[path_idx]
|
||||
except Exception:
|
||||
return []
|
||||
if not os.path.isdir(base_dir):
|
||||
return []
|
||||
models = []
|
||||
for root, dirs, _files in os.walk(base_dir):
|
||||
dirs.sort(key=str.lower)
|
||||
rel = os.path.relpath(root, base_dir).replace('\\', '/')
|
||||
models += _collect_folder_models(root, folder_type, path_idx, '' if rel == '.' else rel, 1, 0)["models"]
|
||||
return models
|
||||
|
||||
|
||||
async def api_get_type_models(request):
|
||||
"""GET /anomalous/type_models?type&path_idx - {models, total}; each model names its own subfolder."""
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except ValueError:
|
||||
path_idx = -1
|
||||
if path_idx < 0:
|
||||
return web.json_response({"status": "error", "message": "Invalid path_idx"}, status=400)
|
||||
models = await asyncio.to_thread(_collect_type_models, request.query.get('type', ''), path_idx)
|
||||
queue_card_images(_cover_paths(models))
|
||||
return web.json_response({"models": models, "total": len(models)})
|
||||
|
||||
|
||||
def _cover_paths(models):
|
||||
"""The cover file of each listed model: it sits beside the model, named in its preview URL."""
|
||||
paths = []
|
||||
for model in models:
|
||||
query = urllib.parse.urlparse(model.get("preview_url") or "").query
|
||||
name = urllib.parse.parse_qs(query).get("filename", [""])[0]
|
||||
if name and model.get("file_path"):
|
||||
paths.append(os.path.join(os.path.dirname(model["file_path"]), name))
|
||||
return paths
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Saving one canvas node's current values as a parameter material (the current-node panel's "save")."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import materials
|
||||
from .material_schema import MAX_MATERIAL_NAME_LENGTH, _material_summary, _normalise_material_tags
|
||||
from .notebooks import MAX_NOTEBOOK_BYTES
|
||||
|
||||
|
||||
def _node_material(payload):
|
||||
node = payload.get("node")
|
||||
if not isinstance(node, dict):
|
||||
raise ValueError("Invalid node")
|
||||
node_type = node.get("type")
|
||||
if not isinstance(node_type, str) or not node_type.strip() or len(node_type) > 200:
|
||||
raise ValueError("Invalid node type")
|
||||
values = node.get("widgets_values")
|
||||
if not isinstance(values, list) or not values:
|
||||
raise ValueError("Invalid node values")
|
||||
encoded = json.dumps([node_type, values], ensure_ascii=False, allow_nan=False, sort_keys=True)
|
||||
if len(encoded.encode("utf-8")) > MAX_NOTEBOOK_BYTES:
|
||||
raise ValueError("Node values are too large")
|
||||
title = node.get("title") if isinstance(node.get("title"), str) else ""
|
||||
title = title.strip()[:200] or node_type
|
||||
name = payload.get("name") or title
|
||||
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_MATERIAL_NAME_LENGTH:
|
||||
raise ValueError("Invalid material name")
|
||||
return {
|
||||
"schema_version": materials.MATERIAL_SCHEMA_VERSION,
|
||||
"id": uuid.uuid4().hex,
|
||||
"kind": "node_parameter_selection",
|
||||
"name": name,
|
||||
"tags": _normalise_material_tags(payload.get("tags", [])),
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"source": {
|
||||
"type": "canvas_node",
|
||||
"parameter_name": title,
|
||||
"parameter_signature": hashlib.sha256(encoded.encode("utf-8")).hexdigest(),
|
||||
},
|
||||
"workflow": {"nodes": [{"id": 1, "type": node_type, "title": title, "widgets_values": values}]},
|
||||
"selection": {"scope": "nodes", "node_ids": [1]},
|
||||
"capabilities": ["apply_node_parameters"],
|
||||
}
|
||||
|
||||
|
||||
async def api_save_node_material(request):
|
||||
try:
|
||||
material = _node_material(await request.json())
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid node material"}, status=400)
|
||||
filename = f"material_{int(time.time())}_{material['id']}.json"
|
||||
try:
|
||||
duplicate = await asyncio.to_thread(
|
||||
materials._persist_parameter_material, materials.get_materials_dir(), filename, material, False)
|
||||
except (OSError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Could not save node material"}, status=500)
|
||||
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)})
|
||||
@@ -1,77 +1,140 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
from .utils import require_filename, resolve_within
|
||||
from .trash import move_to_trash
|
||||
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):
|
||||
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})
|
||||
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:
|
||||
data = await request.json()
|
||||
filename = data.get("filename", "")
|
||||
if not filename.endswith('.json'):
|
||||
filename += '.json'
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
|
||||
|
||||
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 = resolve_within(nb_dir, filename)
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data.get("data", {}), f, indent=4, ensure_ascii=False)
|
||||
|
||||
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 Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
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:
|
||||
path = resolve_within(get_notebooks_dir(), filename)
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(path)
|
||||
move_to_trash(path)
|
||||
|
||||
|
||||
async def api_delete_notebook(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename", "")
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
nb_dir = os.path.join(script_dir, "notebooks")
|
||||
file_path = resolve_within(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)})
|
||||
|
||||
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)
|
||||
|
||||
@@ -8,7 +8,11 @@ import re
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
from .utils import require_filename, resolve_within
|
||||
from .recipes import MAX_RECIPE_BYTES, _parameter_signature, _parameter_gallery_images, get_recipes_dir, _read_recipe
|
||||
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
|
||||
from .trash import move_to_trash
|
||||
|
||||
def get_parameters_dir():
|
||||
# Store parameter notebooks in the user directory
|
||||
@@ -292,14 +296,59 @@ async def api_delete_parameter(request):
|
||||
try:
|
||||
parameters_dir = get_parameters_dir()
|
||||
file_path = resolve_within(parameters_dir, filename)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
move_to_trash(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:
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -14,11 +14,15 @@ import zipfile
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import recipes as recipe_store
|
||||
from .recipes import get_recipes_dir
|
||||
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
|
||||
@@ -155,12 +159,14 @@ def _validate_manifest(manifest, archive, names):
|
||||
raise ValueError("Package checksum mismatch")
|
||||
|
||||
|
||||
def _sanitize_recipe_for_export(recipe, include_snapshots=True, include_identity=True):
|
||||
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:
|
||||
@@ -209,7 +215,8 @@ 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
|
||||
recipe = _sanitize_recipe_for_export(raw_recipe, include_snapshots, include_identity)
|
||||
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):
|
||||
@@ -219,7 +226,12 @@ def _build_export(raw_recipe, recipes_dir, filename, options):
|
||||
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)))
|
||||
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))
|
||||
@@ -230,7 +242,7 @@ def _build_export(raw_recipe, recipes_dir, filename, options):
|
||||
_add_zip_entry(archive, entries, "recipe.json", recipe_bytes, "application/json")
|
||||
|
||||
if asset_ids:
|
||||
assets_dir = recipe_store._recipe_assets_dir(recipes_dir, filename)
|
||||
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):
|
||||
@@ -254,6 +266,7 @@ def _build_export(raw_recipe, recipes_dir, filename, options):
|
||||
"include_snapshots": include_snapshots,
|
||||
"include_history": include_history,
|
||||
"include_identity": include_identity,
|
||||
"include_model_notes": include_model_notes,
|
||||
},
|
||||
"entries": entries,
|
||||
}
|
||||
@@ -284,14 +297,14 @@ def _inspect_package(raw):
|
||||
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_store._normalise_recipe(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_store._normalise_recipe(history)
|
||||
recipe_schema._normalise_recipe(history)
|
||||
history_recipes.append(history)
|
||||
_validate_recipe_assets(archive, [recipe, *history_recipes], names)
|
||||
return {
|
||||
@@ -381,14 +394,14 @@ def _commit_import(record, payload):
|
||||
else:
|
||||
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), existing_names)
|
||||
|
||||
normalized = recipe_store._normalise_recipe(package_recipe)
|
||||
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_store._enrich_recipe(normalized)
|
||||
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_store._recipe_assets_dir(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
|
||||
@@ -448,6 +461,12 @@ def _commit_import(record, payload):
|
||||
|
||||
|
||||
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", ""))
|
||||
@@ -470,6 +489,11 @@ async def api_export_recipe_package(request):
|
||||
|
||||
|
||||
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)
|
||||
@@ -489,6 +513,11 @@ async def api_import_recipe_package_inspect(request):
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
# All-in-one loaders with a verified serialized widget layout. Must stay in sync
|
||||
# with ALL_IN_ONE_LOADER_SPECS in web/modules/recipe_identity.js.
|
||||
ALL_IN_ONE_LOADER_SPECS = {
|
||||
# ComfyUI-Easy-Use: ckpt_name, vae_name, clip_skip, lora_name, ...
|
||||
"easy a1111loader": ((0, "checkpoint", "ckpt_name"), (1, "vae", "vae_name"), (3, "lora", "lora_name")),
|
||||
# ComfyUI-Easy-Use: ckpt_name, config_name, vae_name, clip_skip, lora_name, ...
|
||||
"easy fullloader": ((0, "checkpoint", "ckpt_name"), (2, "vae", "vae_name"), (4, "lora", "lora_name")),
|
||||
# efficiency-nodes-comfyui: ckpt_name, vae_name, clip_skip, lora_name, ...
|
||||
"efficient loader": ((0, "checkpoint", "ckpt_name"), (1, "vae", "vae_name"), (3, "lora", "lora_name")),
|
||||
}
|
||||
PLACEHOLDER_MODEL_VALUES = {"none", "baked vae"}
|
||||
|
||||
|
||||
def _model_reference_specs(node):
|
||||
"""Known loader adapters only; arbitrary third-party widgets stay parameters."""
|
||||
node_type = _node_type(node)
|
||||
lowered = node_type.lower().strip()
|
||||
specs = []
|
||||
|
||||
if lowered in ALL_IN_ONE_LOADER_SPECS:
|
||||
specs.extend(ALL_IN_ONE_LOADER_SPECS[lowered])
|
||||
elif 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
|
||||
if saved_value.strip().lower() in PLACEHOLDER_MODEL_VALUES:
|
||||
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,139 @@
|
||||
"""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 .trash import move_to_trash
|
||||
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):
|
||||
"""Moves an explicitly selected recipe, its history and its images to the Recycle Bin."""
|
||||
path = resolve_within(recipes_dir, filename)
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(path)
|
||||
move_to_trash(path, _history_dir(recipes_dir, filename), _recipe_assets_dir(recipes_dir, filename))
|
||||
@@ -0,0 +1,166 @@
|
||||
"""What a scan did, model by model.
|
||||
|
||||
The scraper appends one JSON line per model it changed to a report file (--report-file),
|
||||
plus events: "civitai_down" when Civitai stopped answering, "done" with the count of models
|
||||
left as they were. When a scan ends, ScanJob turns the lines of all its folders into one
|
||||
result: kept as the last scan (GET /anomalous/last_scan, shown on the scan page) and recorded
|
||||
in the activity log, with each model located by type, path index and relative path so the
|
||||
browser can open it (GET /anomalous/scan_model).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .activity_log import MAX_CHANGES, add_entry, log_path
|
||||
from .folder_types import get_active_folder_types
|
||||
from .model_catalog import _model_info_for_path
|
||||
from .path_utils import atomic_write_json, resolve_folder_subdir
|
||||
|
||||
MAX_FILES = 2000 # models kept in the last scan's result
|
||||
FILE_FIELDS = ("status", "reason", "name", "base", "cover", "error")
|
||||
|
||||
|
||||
def last_scan_path():
|
||||
return os.path.join(os.path.dirname(log_path()), "last_scan.json")
|
||||
|
||||
|
||||
def locate_root(directory):
|
||||
"""(type, path_idx) of the models folder that is `directory`, shown types first; None if none."""
|
||||
target = os.path.normcase(os.path.realpath(directory))
|
||||
active = get_active_folder_types()
|
||||
types = active + [name for name in folder_paths.folder_names_and_paths if name not in active]
|
||||
for folder_type in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type) or []
|
||||
except Exception:
|
||||
continue
|
||||
for path_idx, path in enumerate(paths):
|
||||
if os.path.normcase(os.path.realpath(path)) == target:
|
||||
return folder_type, path_idx
|
||||
return None
|
||||
|
||||
|
||||
def read_report(path):
|
||||
"""(model entries, events) from a report file; both empty when there is none."""
|
||||
entries, events = [], []
|
||||
try:
|
||||
with open(path, encoding="utf-8") as source:
|
||||
for line in source:
|
||||
try:
|
||||
item = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(item, dict):
|
||||
(events if "event" in item else entries).append(item)
|
||||
except OSError:
|
||||
pass
|
||||
return entries, events
|
||||
|
||||
|
||||
class ScanJob:
|
||||
"""One scan from start to end: what to call it, its options, and its folders' reports."""
|
||||
|
||||
def __init__(self, kind, data):
|
||||
self.kind = kind # "all" | "picked" | "one"
|
||||
self.options = {key: bool(data.get(key)) for key in ("offline_only", "retry_unmatched", "force_overwrite")}
|
||||
self.started = time.time()
|
||||
self.files = []
|
||||
self.unchanged = 0
|
||||
self.civitai_down = False
|
||||
self.errors = []
|
||||
|
||||
def add_folder(self, report_file, base_dir, located=None):
|
||||
"""Reads (and removes) one scraper run's report. `located`: (type, path_idx) of base_dir."""
|
||||
located = located or locate_root(base_dir) or ("", 0)
|
||||
entries, events = read_report(report_file)
|
||||
try:
|
||||
os.remove(report_file)
|
||||
except OSError:
|
||||
pass
|
||||
for event in events:
|
||||
if event.get("event") == "done":
|
||||
self.unchanged += int(event.get("unchanged") or 0)
|
||||
elif event.get("event") == "civitai_down":
|
||||
self.civitai_down = True
|
||||
for entry in entries:
|
||||
path = entry.get("renamed_to") if entry.get("renamed_to") and entry.get("status") != "failed" else entry.get("file")
|
||||
if not isinstance(path, str):
|
||||
continue
|
||||
item = {key: entry[key] for key in FILE_FIELDS if entry.get(key)}
|
||||
item.update(type=located[0], path_idx=located[1], filename=os.path.basename(path),
|
||||
rel=os.path.relpath(path, base_dir).replace(os.sep, "/"))
|
||||
if entry.get("renamed_to") and item["status"] != "failed":
|
||||
item["renamed_from"] = os.path.basename(entry.get("file") or "")
|
||||
if entry.get("duplicate_of"):
|
||||
item["duplicate"] = True
|
||||
self.files.append(item)
|
||||
|
||||
def add_error(self, message):
|
||||
self.errors.append(str(message)[:300])
|
||||
|
||||
def result(self):
|
||||
files = self.files
|
||||
counts = {
|
||||
"matched": sum(item["status"] == "matched" for item in files),
|
||||
"inferred": sum(item["status"] == "inferred" for item in files),
|
||||
"failed": sum(item["status"] == "failed" for item in files),
|
||||
"covers": sum(item.get("cover") == "new" for item in files),
|
||||
"renamed": sum(bool(item.get("renamed_from")) for item in files),
|
||||
"unchanged": self.unchanged,
|
||||
}
|
||||
return {
|
||||
"kind": self.kind, "options": self.options, "started": self.started,
|
||||
"finished": time.time(), "counts": counts, "civitai_down": self.civitai_down,
|
||||
"errors": self.errors[:10], "files": files[:MAX_FILES],
|
||||
}
|
||||
|
||||
def finish(self):
|
||||
"""Keeps the result as the last scan and records it in the activity log. Never raises:
|
||||
the scan itself is done, the record of it is a convenience."""
|
||||
result = self.result()
|
||||
try:
|
||||
atomic_write_json(last_scan_path(), result)
|
||||
except Exception as error: # noqa: BLE001
|
||||
print(f"[Anomalous Browser] Could not keep the scan result: {error}")
|
||||
try:
|
||||
target = result["files"][0]["filename"] if self.kind == "one" and result["files"] else ""
|
||||
detail = {"scan": {key: result[key] for key in ("kind", "options", "counts", "civitai_down", "errors")},
|
||||
"files": result["files"][:MAX_CHANGES], "total": len(self.files),
|
||||
"seconds": round(result["finished"] - result["started"])}
|
||||
add_entry("file", "scan_done", target, detail)
|
||||
except Exception as error: # noqa: BLE001
|
||||
print(f"[Anomalous Browser] Could not record the scan in the activity log: {error}")
|
||||
return result
|
||||
|
||||
|
||||
async def api_last_scan(request):
|
||||
"""GET /anomalous/last_scan - the last scan's result, or {} before the first one."""
|
||||
def read():
|
||||
try:
|
||||
with open(last_scan_path(), encoding="utf-8") as source:
|
||||
data = json.load(source)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return web.json_response(await asyncio.to_thread(read))
|
||||
|
||||
|
||||
async def api_scan_model(request):
|
||||
"""GET /anomalous/scan_model?type&path_idx&rel - one model as the grid lists it, to open it."""
|
||||
rel = request.query.get("rel", "").replace("\\", "/").strip("/")
|
||||
subfolder, filename = os.path.split(rel)
|
||||
try:
|
||||
path_idx = int(request.query.get("path_idx", 0))
|
||||
base_dir, target_dir = resolve_folder_subdir(request.query.get("type", ""), path_idx, "/" + subfolder)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return web.json_response({"error": "Invalid model location"}, status=400)
|
||||
file_path = os.path.join(target_dir, filename)
|
||||
if not filename or filename in (".", "..") or not os.path.isfile(file_path):
|
||||
return web.json_response({"error": "Model not found"}, status=404)
|
||||
model = await asyncio.to_thread(_model_info_for_path, request.query.get("type"), path_idx, base_dir, file_path)
|
||||
return web.json_response({"model": model})
|
||||
@@ -0,0 +1,71 @@
|
||||
"""What the scan page shows before a scan: how many models are matched on Civitai, unmatched or
|
||||
new, and which ones the last two are (located like the scan report's models, so the page can
|
||||
open them); also the model files a scan never reads (other formats than .safetensors), so the
|
||||
page can say why they are not there."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .folder_types import get_active_scan_paths
|
||||
from .scan_report import locate_root
|
||||
|
||||
try:
|
||||
from ..model_identity import is_unmatched, sidecar_info, unmatched_reason
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_identity import is_unmatched, sidecar_info, unmatched_reason
|
||||
|
||||
MAX_LISTED = 2000 # models listed per kind; the counts stay exact
|
||||
# Model files in other formats: listed as "not scanned" (scans read only .safetensors).
|
||||
OTHER_MODEL_EXTENSIONS = (".ckpt", ".pt", ".pth", ".bin", ".gguf", ".onnx", ".sft", ".engine")
|
||||
|
||||
|
||||
def _summarize():
|
||||
"""Counts the files the scanner handles (.safetensors in the active scan folders), each once.
|
||||
`pending`: unmatched models Civitai was never asked about (offline, or unreachable then),
|
||||
which the next online scan looks up. `skipped`: model files in other formats, outside hidden
|
||||
folders (tools keep their working files there)."""
|
||||
seen = set()
|
||||
total = matched = 0
|
||||
unmatched, new, skipped = [], [], []
|
||||
for base_dir in get_active_scan_paths():
|
||||
located = locate_root(base_dir) or ("", 0)
|
||||
for root, _dirs, files in os.walk(base_dir):
|
||||
hidden = any(part.startswith(".") for part in os.path.relpath(root, base_dir).split(os.sep) if part != ".")
|
||||
for filename in files:
|
||||
other = filename.lower().endswith(OTHER_MODEL_EXTENSIONS)
|
||||
if not filename.endswith(".safetensors") and not (other and not hidden):
|
||||
continue
|
||||
path = os.path.join(root, filename)
|
||||
real = os.path.realpath(path)
|
||||
if real in seen:
|
||||
continue
|
||||
seen.add(real)
|
||||
if other:
|
||||
skipped.append({"type": located[0], "path_idx": located[1], "filename": filename,
|
||||
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")})
|
||||
continue
|
||||
total += 1
|
||||
info = sidecar_info(os.path.splitext(path)[0])
|
||||
if info is not None and not is_unmatched(info):
|
||||
matched += 1
|
||||
continue
|
||||
item = {"type": located[0], "path_idx": located[1], "filename": filename,
|
||||
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")}
|
||||
if info is None:
|
||||
new.append(item)
|
||||
else:
|
||||
item.update(reason=unmatched_reason(info), base=info.get("baseModel") or "")
|
||||
unmatched.append(item)
|
||||
return {
|
||||
"total": total, "matched": matched, "unmatched": len(unmatched), "new": len(new),
|
||||
"pending": sum(item["reason"] in ("offline", "network") for item in unmatched),
|
||||
"unmatched_models": unmatched[:MAX_LISTED], "new_models": new[:MAX_LISTED],
|
||||
"skipped": len(skipped), "skipped_models": skipped[:MAX_LISTED],
|
||||
}
|
||||
|
||||
|
||||
async def api_scan_summary(request):
|
||||
"""GET /anomalous/scan_summary - counts, and the unmatched and new models."""
|
||||
return web.json_response(await asyncio.to_thread(_summarize))
|
||||
@@ -5,10 +5,245 @@ 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 .utils import get_active_folder_types, get_active_scan_paths, resolve_folder_subdir
|
||||
from .folder_types import get_active_scan_paths
|
||||
from .path_utils import resolve_folder_subdir
|
||||
from .scan_report import ScanJob, locate_root
|
||||
try:
|
||||
from ..model_policies import is_physical_rename_protected
|
||||
except ImportError:
|
||||
from model_policies import is_physical_rename_protected
|
||||
|
||||
|
||||
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SCRAPER_PATH = os.path.join(PLUGIN_DIR, "scraper.py")
|
||||
SCAN_SESSION_ID = uuid.uuid4().hex
|
||||
SCAN_MARKER_LOCK = threading.Lock()
|
||||
ACTIVE_SCAN_MARKERS = set()
|
||||
SCAN_RUNTIME_STATE = {}
|
||||
|
||||
|
||||
# Request field -> scraper switch, with the field's default. Both scan routes read these.
|
||||
_SCRAPER_FLAGS = (
|
||||
("offline_only", "--offline-only", False),
|
||||
("virtual_rename", "--virtual-rename", False),
|
||||
("force_overwrite", "--force-overwrite", False),
|
||||
("retry_unmatched", "--retry-unmatched", False),
|
||||
("skip_media", "--skip-media", False),
|
||||
)
|
||||
|
||||
|
||||
def _scraper_flags(data, skip_rename_default, physical_allowed):
|
||||
"""The scraper's command-line switches for one scan request."""
|
||||
flags = [flag for field, flag, default in _SCRAPER_FLAGS if data.get(field, default)]
|
||||
if data.get("skip_rename", skip_rename_default):
|
||||
flags.append("--skip-rename")
|
||||
if data.get("physical_rename", False) and physical_allowed:
|
||||
flags.append("--physical-rename")
|
||||
if not data.get("use_local_metadata", True):
|
||||
flags.append("--skip-local-metadata")
|
||||
return flags
|
||||
|
||||
|
||||
def _clear_folder_caches():
|
||||
"""ComfyUI lists model folders from caches; a scan may have renamed files."""
|
||||
for cache in (getattr(folder_paths, "filename_list_cache", None), getattr(folder_paths, "cache_helper", None)):
|
||||
try:
|
||||
cache.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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')
|
||||
@@ -21,19 +256,20 @@ async def api_scan_status(request):
|
||||
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})
|
||||
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')
|
||||
scanning = os.path.exists(marker_file)
|
||||
|
||||
data = {"scanning": scanning}
|
||||
if not scanning and os.path.exists(result_file):
|
||||
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)
|
||||
@@ -44,361 +280,196 @@ async def api_scan_status(request):
|
||||
return web.json_response(data)
|
||||
|
||||
async def api_scan_folder(request):
|
||||
"""Launches the scraper in the background for a specific directory."""
|
||||
"""Launches the scraper in the background for one folder (a model card's scan)."""
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
subfolder = request.query.get('subfolder', '/')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
except (TypeError, ValueError):
|
||||
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"})
|
||||
|
||||
|
||||
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):
|
||||
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 Exception:
|
||||
data = {}
|
||||
|
||||
offline_only = data.get("offline_only", False)
|
||||
skip_rename = data.get("skip_rename", False)
|
||||
virtual_rename = data.get("virtual_rename", False)
|
||||
physical_rename = data.get("physical_rename", False)
|
||||
force_overwrite = data.get("force_overwrite", False)
|
||||
|
||||
except ValueError:
|
||||
data = None
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
|
||||
|
||||
flags = _scraper_flags(data, False, not is_physical_rename_protected(
|
||||
folder_type=folder_type,
|
||||
folder_path=target_dir,
|
||||
))
|
||||
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 []
|
||||
|
||||
try:
|
||||
marker_file = os.path.join(target_dir, '.scan_in_progress')
|
||||
try:
|
||||
with open(marker_file, 'x') as f:
|
||||
f.write('1')
|
||||
except FileExistsError:
|
||||
return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409)
|
||||
|
||||
if target_files_list:
|
||||
targets_file = os.path.join(target_dir, '.scan_targets.json')
|
||||
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]
|
||||
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")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=plugin_dir
|
||||
)
|
||||
if result.returncode != 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 {result.returncode}"}, f)
|
||||
|
||||
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')
|
||||
report_file = os.path.join(target_dir, '.scan_report.jsonl')
|
||||
artifacts = (progress_file, targets_file, report_file)
|
||||
claimed, recovered = _claim_scan_marker(marker_file, "folder", artifacts)
|
||||
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:
|
||||
json.dump(target_files_list, f)
|
||||
job = ScanJob("one" if len(target_files_list) == 1 else "picked", data)
|
||||
|
||||
def run_bg():
|
||||
try:
|
||||
cmd = [sys.executable, SCRAPER_PATH, target_dir, "--folder-type", folder_type, *flags]
|
||||
cmd.extend(["--progress-file", progress_file, "--report-file", report_file])
|
||||
process = subprocess.Popen(cmd, cwd=PLUGIN_DIR)
|
||||
_update_scan_marker(marker_file, worker_pid=process.pid)
|
||||
return_code = process.wait()
|
||||
job.add_folder(report_file, base_dir, (folder_type, path_idx))
|
||||
if return_code != 0:
|
||||
job.add_error(f"Scanner exited with code {return_code}")
|
||||
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
|
||||
|
||||
if os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except: pass
|
||||
|
||||
_clear_folder_caches()
|
||||
job.finish() # before the marker goes: whoever sees the scan end finds its result
|
||||
_release_scan_marker(marker_file, artifacts)
|
||||
|
||||
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:
|
||||
if 'marker_file' in locals() and os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except OSError: pass
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
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)})
|
||||
|
||||
|
||||
def _global_scan_plan(data):
|
||||
"""The folders a scan-page scan runs through: [(scan dir, (type, path_idx) or None, base dir,
|
||||
file names or None, folder type for the rename policy)]. `data["targets"]`, when given, is
|
||||
the picked models: [{type, path_idx, subfolder, files}]; otherwise every active folder."""
|
||||
targets = data.get("targets")
|
||||
if not targets:
|
||||
plan = []
|
||||
for base_dir in get_active_scan_paths():
|
||||
if os.path.exists(base_dir):
|
||||
plan.append((base_dir, locate_root(base_dir), base_dir, None, _protected_type_for_path(base_dir)))
|
||||
return plan
|
||||
if not isinstance(targets, list):
|
||||
raise ValueError("targets must be a list")
|
||||
plan = []
|
||||
for target in targets:
|
||||
files = target.get("files") if isinstance(target, dict) else None
|
||||
if not isinstance(files, list) or not all(isinstance(name, str) for name in files):
|
||||
raise ValueError("each target needs a list of file names")
|
||||
path_idx = int(target.get("path_idx", 0))
|
||||
base_dir, target_dir = resolve_folder_subdir(target.get("type"), path_idx, target.get("subfolder") or "/")
|
||||
if files and os.path.isdir(target_dir):
|
||||
plan.append((target_dir, (target["type"], path_idx), base_dir, files, target["type"]))
|
||||
return plan
|
||||
|
||||
|
||||
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')
|
||||
|
||||
"""Scans every active models folder, or the picked models (`targets`), one folder after another."""
|
||||
marker_file = os.path.join(PLUGIN_DIR, '.global_scan_in_progress')
|
||||
progress_file = os.path.join(PLUGIN_DIR, '.global_scan_progress.json')
|
||||
targets_file = os.path.join(PLUGIN_DIR, '.global_scan_targets.json')
|
||||
report_file = os.path.join(PLUGIN_DIR, '.global_scan_report.jsonl')
|
||||
artifacts = (progress_file, targets_file, report_file)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
try:
|
||||
with open(marker_file, 'x') as f:
|
||||
f.write('1')
|
||||
except FileExistsError:
|
||||
return web.json_response({"status": "error", "message": "Global scan already in progress"}, status=409)
|
||||
|
||||
except ValueError:
|
||||
data = None
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
|
||||
try:
|
||||
plan = _global_scan_plan(data) if data.get("targets") else None
|
||||
except (ValueError, KeyError, TypeError) as e:
|
||||
return web.json_response({"status": "error", "message": f"Invalid targets: {e}"}, status=400)
|
||||
picked = sum(len(files) for _dir, _loc, _base, files, _type in plan or ())
|
||||
job = ScanJob("all" if plan is None else "one" if picked == 1 else "picked", data)
|
||||
|
||||
claimed = False
|
||||
try:
|
||||
claimed, recovered = _claim_scan_marker(marker_file, "global", artifacts)
|
||||
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()
|
||||
for base_dir in paths_to_scan:
|
||||
if not os.path.exists(base_dir): continue
|
||||
folders = plan if plan is not None else _global_scan_plan(data)
|
||||
_update_scan_state(marker_file, phase="preparing", folder_total=len(folders), folder_current=0, folder="")
|
||||
for folder_index, (scan_dir, located, base_dir, files, policy_type) in enumerate(folders, 1):
|
||||
try:
|
||||
print(f"[Anomalous Browser] Global scan processing: {base_dir}")
|
||||
cmd = [sys.executable, scraper_path, base_dir]
|
||||
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")
|
||||
if skip_media:
|
||||
cmd.append("--skip-media")
|
||||
if not use_local_metadata:
|
||||
cmd.append("--skip-local-metadata")
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=plugin_dir
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"[Anomalous Browser] Scanner exited with code {result.returncode}: {base_dir}")
|
||||
print(f"[Anomalous Browser] Global scan processing: {scan_dir}")
|
||||
_remove_file(progress_file)
|
||||
_update_scan_state(
|
||||
marker_file,
|
||||
phase="enumerating",
|
||||
folder_total=len(folders),
|
||||
folder_current=folder_index,
|
||||
folder=os.path.basename(os.path.normpath(scan_dir)) or scan_dir,
|
||||
error="",
|
||||
)
|
||||
cmd = [sys.executable, SCRAPER_PATH, scan_dir]
|
||||
if policy_type:
|
||||
cmd.extend(["--folder-type", policy_type])
|
||||
cmd.extend(_scraper_flags(data, True, not is_physical_rename_protected(
|
||||
folder_type=policy_type,
|
||||
folder_path=scan_dir,
|
||||
)))
|
||||
if files is not None:
|
||||
with open(targets_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(files, f)
|
||||
cmd.extend(["--targets-file", targets_file])
|
||||
if job.civitai_down: # an earlier folder found Civitai unreachable
|
||||
cmd.append("--civitai-down")
|
||||
cmd.extend(["--progress-file", progress_file, "--report-file", report_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)
|
||||
job.add_folder(report_file, base_dir, located)
|
||||
if return_code != 0:
|
||||
message = f"Scanner exited with code {return_code}: {scan_dir}"
|
||||
_update_scan_state(marker_file, error=message)
|
||||
job.add_error(message)
|
||||
print(f"[Anomalous Browser] {message}")
|
||||
except Exception as e:
|
||||
print(f"[Anomalous Browser] Global scan error on {base_dir}: {e}")
|
||||
_update_scan_state(marker_file, error=str(e))
|
||||
job.add_error(f"{scan_dir}: {e}")
|
||||
print(f"[Anomalous Browser] Global scan error on {scan_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
|
||||
|
||||
if os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except: pass
|
||||
|
||||
_clear_folder_caches()
|
||||
job.finish() # before the marker goes: whoever sees the scan end finds its result
|
||||
_release_scan_marker(marker_file, artifacts)
|
||||
|
||||
threading.Thread(target=run_global_bg, daemon=True).start()
|
||||
return web.json_response({"status": "ok", "message": "Global scan started"})
|
||||
except Exception as e:
|
||||
if os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except OSError: pass
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
return web.json_response({"status": "ok", "message": "Global scan started", "recovered": recovered})
|
||||
except Exception as e:
|
||||
if claimed:
|
||||
_release_scan_marker(marker_file, artifacts)
|
||||
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')
|
||||
return web.json_response({"scanning": os.path.exists(marker_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 extract_safetensors_hash, calculate_sha256, fetch_civitai_info, infer_base_model_from_header
|
||||
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)
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
info_path = base_path + ".info"
|
||||
civitai_info_path = base_path + ".civitai.info"
|
||||
|
||||
# Check if a valid info file exists
|
||||
has_valid_info = False
|
||||
actual_info = info_path if os.path.exists(info_path) else (civitai_info_path if os.path.exists(civitai_info_path) else None)
|
||||
|
||||
if force_overwrite:
|
||||
actual_info = None
|
||||
|
||||
if actual_info:
|
||||
try:
|
||||
with open(actual_info, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
for fi in data.get("files", []):
|
||||
if isinstance(fi, dict) and "hashes" in fi and "SHA256" in fi["hashes"]:
|
||||
if fi["hashes"]["SHA256"]:
|
||||
has_valid_info = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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:
|
||||
civitai_data = None
|
||||
file_hash = None
|
||||
|
||||
# Fallback 1: Try header hash on Civitai
|
||||
header_hash = extract_safetensors_hash(file_path)
|
||||
if header_hash:
|
||||
civitai_data = fetch_civitai_info(header_hash)
|
||||
if civitai_data:
|
||||
file_hash = header_hash
|
||||
|
||||
# Fallback 2: If header hash fails, compute full SHA256
|
||||
if not civitai_data:
|
||||
full_hash = calculate_sha256(file_path)
|
||||
civitai_data = fetch_civitai_info(full_hash)
|
||||
file_hash = full_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 = ""
|
||||
|
||||
if not file_hash:
|
||||
file_hash = header_hash or calculate_sha256(file_path)
|
||||
|
||||
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}}]
|
||||
}
|
||||
|
||||
# Ensure SHA256 is explicitly injected into the info data
|
||||
if civitai_data:
|
||||
if not civitai_data.get("files"):
|
||||
civitai_data["files"] = [{"hashes": {"SHA256": file_hash}}]
|
||||
elif isinstance(civitai_data["files"][0], dict):
|
||||
if "hashes" not in civitai_data["files"][0]:
|
||||
civitai_data["files"][0]["hashes"] = {}
|
||||
civitai_data["files"][0]["hashes"]["SHA256"] = 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"})
|
||||
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,)))
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Bounded translation-provider HTTP route."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
_DEEPL_TARGETS = {
|
||||
"zh": "ZH", "zh-CN": "ZH", "zh-TW": "ZH-HANT", "en": "EN", "ja": "JA", "ko": "KO",
|
||||
"fr": "FR", "de": "DE", "es": "ES", "ru": "RU", "pt": "PT-BR",
|
||||
}
|
||||
|
||||
|
||||
def _translate_with_deepl(text, tl, deepl_key):
|
||||
"""None for a target DeepL does not take: the other providers are asked instead."""
|
||||
d_tl = _DEEPL_TARGETS.get(tl)
|
||||
if not d_tl:
|
||||
return None
|
||||
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)
|
||||
# Text that is not plain English and not Chinese (kana, hangul, accents...) is left to its detection.
|
||||
sl = 'zh-CN' if has_cn else ('en' if text.isascii() else 'Autodetect')
|
||||
pair_tl = '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,19 @@
|
||||
"""The API's way to delete the user's files: recycle_bin.py (shared with the scanner)."""
|
||||
|
||||
try:
|
||||
from ..recycle_bin import TrashUnavailable, move_to_trash
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from recycle_bin import TrashUnavailable, move_to_trash
|
||||
|
||||
__all__ = ["TrashUnavailable", "move_to_trash", "trash_failure"]
|
||||
|
||||
|
||||
def trash_failure(exc):
|
||||
"""What to tell the user when moving to the Recycle Bin did not happen."""
|
||||
if isinstance(exc, TrashUnavailable):
|
||||
return ("这个位置没有回收站(U 盘或网络盘),为了安全没有删除,请在文件管理器里手动删除。"
|
||||
" / No Recycle Bin here (USB or network drive); nothing was deleted, please delete it in your file manager.")
|
||||
if getattr(exc, "errno", None) == 32 or getattr(exc, "winerror", None) == 32 or "being used" in str(exc):
|
||||
return ("文件正在被使用(可能 ComfyUI 已经加载了它),没有删除。请先卸载模型或重启 ComfyUI。"
|
||||
" / The file is in use (ComfyUI may have it loaded); nothing was deleted.")
|
||||
return f"没有删除 / Not deleted: {exc}"
|
||||
@@ -1,610 +1,31 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
import hashlib
|
||||
import tempfile
|
||||
import time
|
||||
"""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 *
|
||||
|
||||
|
||||
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
|
||||
_gallery_snapshot = _gallery._gallery_snapshot
|
||||
_gallery_collect_impl = _gallery._collect_gallery_images
|
||||
_gallery_images_impl = _gallery._gallery_images
|
||||
|
||||
|
||||
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 _collect_gallery_images(output_dir):
|
||||
return _gallery_collect_impl(output_dir)
|
||||
|
||||
|
||||
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 _gallery_images(output_dir, refresh=False):
|
||||
_gallery._collect_gallery_images = _collect_gallery_images
|
||||
return _gallery_images_impl(output_dir, refresh)
|
||||
|
||||
|
||||
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 _invalidate_gallery_snapshot():
|
||||
return _gallery._invalidate_gallery_snapshot()
|
||||
|
||||
|
||||
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 _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)})
|
||||
|
||||
|
||||
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_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 = max(1, int(request.query.get('page', 1)))
|
||||
limit = min(200, max(1, int(request.query.get('limit', 50))))
|
||||
|
||||
valid_exts = {'.png', '.jpg', '.jpeg', '.webp', '.gif'}
|
||||
def collect_images():
|
||||
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 OSError:
|
||||
mtime = 0
|
||||
images.append({
|
||||
"filename": f,
|
||||
"subfolder": subfolder,
|
||||
"type": "output",
|
||||
"mtime": mtime
|
||||
})
|
||||
images.sort(key=lambda x: x['mtime'], reverse=True)
|
||||
return images
|
||||
|
||||
images = await asyncio.to_thread(collect_images)
|
||||
|
||||
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", "")
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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})
|
||||
|
||||
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:
|
||||
active.append(bn)
|
||||
return active
|
||||
except:
|
||||
pass
|
||||
|
||||
return all_bns
|
||||
|
||||
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', '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
|
||||
|
||||
default_physical_types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
|
||||
for bn in all_bns:
|
||||
if bn not in configured:
|
||||
result.append({
|
||||
"type": bn,
|
||||
"visible": bn in default_physical_types
|
||||
})
|
||||
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})
|
||||
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,107 @@
|
||||
"""Civitai over the network, for the scanner: a model version by file hash, a model page's
|
||||
description, and preview media. The API key comes from the plugin's config.json.
|
||||
|
||||
fetch_civitai_info tells the two failures apart: None means Civitai answered that it does not
|
||||
know the file; CivitaiUnreachable means it could not be asked (no connection, timeouts, errors).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Dict, Optional
|
||||
|
||||
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"
|
||||
|
||||
|
||||
class CivitaiUnreachable(Exception):
|
||||
"""Civitai did not answer (network, timeout or server error) after the retries."""
|
||||
|
||||
|
||||
def _load_api_key():
|
||||
plugin_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
for config_path in (os.path.join(plugin_dir, "api", "config.json"), os.path.join(plugin_dir, "config.json")):
|
||||
if not os.path.exists(config_path):
|
||||
continue
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
key = json.load(f).get("CIVITAI_API_KEY", "")
|
||||
if isinstance(key, str) and key.strip():
|
||||
return key.strip()
|
||||
except Exception as e:
|
||||
print(f"[-] 读取 config.json 失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
CIVITAI_API_KEY = _load_api_key()
|
||||
|
||||
|
||||
def _headers():
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
if CIVITAI_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {CIVITAI_API_KEY}"
|
||||
return headers
|
||||
|
||||
|
||||
def fetch_civitai_info(file_hash: str, max_retries: int = 3) -> Optional[Dict]:
|
||||
"""Civitai's record of the model version with this file hash; None when Civitai does not
|
||||
know the file (404). Raises CivitaiUnreachable when it could not be asked."""
|
||||
url = f"https://civitai.com/api/v1/model-versions/by-hash/{file_hash}"
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers=_headers()), 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
|
||||
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)
|
||||
print(f"\033[93m[Skip] 模型 Hash {file_hash} 网络重试失败。\033[0m")
|
||||
raise CivitaiUnreachable(file_hash)
|
||||
|
||||
|
||||
def fetch_model_description(model_id) -> str:
|
||||
"""The model page's description (the version record only has the version's); '' when unavailable."""
|
||||
try:
|
||||
request = urllib.request.Request(f"https://civitai.com/api/v1/models/{model_id}", headers=_headers())
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
return json.loads(response.read().decode('utf-8')).get("description") or ""
|
||||
except Exception as e:
|
||||
print(f"[-] 获取模型主页详细说明失败: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def download_media(url: str, base_path: str, max_retries: int = 3):
|
||||
"""Downloads an image or video to base_path + the extension its type calls for; the path, or None."""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers=_headers()), timeout=15) as response:
|
||||
content_type = response.headers.get("Content-Type", "").lower()
|
||||
ext = ".png"
|
||||
if "video/mp4" in content_type: ext = ".mp4"
|
||||
elif "video/webm" in content_type: ext = ".webm"
|
||||
elif "image/jpeg" in content_type: ext = ".jpg"
|
||||
elif "image/webp" in content_type: ext = ".webp"
|
||||
elif url.endswith(".mp4"): ext = ".mp4"
|
||||
final_path = base_path + ext
|
||||
with open(final_path, 'wb') as f:
|
||||
while True:
|
||||
chunk = response.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
return final_path
|
||||
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:
|
||||
pass
|
||||
return 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): saved prompts and node values
|
||||
(material files), keeping an output image, and dragging prompts onto the canvas.
|
||||
- [`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 Check,
|
||||
hash injection, model 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,238 @@
|
||||
# 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.
|
||||
|
||||
## Requests from other devices
|
||||
|
||||
ComfyUI started with `--listen` lets other devices on the network open the page. They may
|
||||
look; every change under `/anomalous/` answers only the computer ComfyUI runs on. One
|
||||
middleware (`api/local_guard.py`, registered first in `setup_routes`) decides it for all routes:
|
||||
a request other than GET must be local unless its path is in `OPEN_POSTS` (reads sent as POST:
|
||||
`resolve_paths_to_previews`, `resolve_hash_batch`, `inspect_image_material`); a GET is open
|
||||
unless its path is in `LOCAL_GETS` (`backup/file`, `version/releases`, `import/folders`).
|
||||
`/anomalous/mcp` answers other devices itself. Local means: a loopback peer, no proxy headers
|
||||
(`Forwarded`, `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Host`) and a loopback `Host`, so a
|
||||
page that reached the server under another name (DNS rebinding) is refused too. A refusal is
|
||||
HTTP 403 with `code: local_only` and a bilingual message. `GET /anomalous/access` tells the page
|
||||
whether it is local; `remote_view.js` then shows a "view only" chip in the header.
|
||||
|
||||
## 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;
|
||||
`node_material.py` saves one canvas node's values as a `node_parameter_selection`
|
||||
material (deduplicated by node type and values like other parameter materials).
|
||||
|
||||
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, `folder_types.py` owns configured visibility and scan scope, and `audio_catalog.py`
|
||||
owns generated audio: the output-audio history, temp Preview Audio results and
|
||||
their copy into `output/audio/`, deletion, and streaming from the output and temp
|
||||
folders only. Character voices are the Anomalous_TTS node's data and never pass
|
||||
through this plugin's backend.
|
||||
|
||||
`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.
|
||||
|
||||
Combos (搭配, formerly 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 scans use `.global_scan_in_progress`.
|
||||
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.
|
||||
|
||||
`scraper.py --report-file` appends one JSON line per model it changed (`matched`,
|
||||
`inferred` with its reason, `failed` with the error, renames and covers) and events
|
||||
(`civitai_down`, `done` with the unchanged count). One model's error is reported and
|
||||
the scan goes on. `ScanJob` in `api/scan_report.py` reads each folder's report when its
|
||||
worker exits and, before the scan's marker is released, writes the scan's result to
|
||||
`user/anomalous/last_scan.json` and the activity log, so whoever sees the scan end can
|
||||
read its result. The scan page's picked or listed models go to `/anomalous/scan_all` as
|
||||
`targets` (`--targets-file` per folder), so one scan has one result.
|
||||
|
||||
An unmatched model's `.info` (`id` -1) keeps why: `anomalous_unmatched_reason` is
|
||||
`not_found` (Civitai answered 404), `network` (no answer after the retries) or
|
||||
`offline`. Online scans look up `network` and `offline` models again; `not_found` ones
|
||||
wait for "look up again". After `CIVITAI_DOWN_AFTER` models in a row without an answer
|
||||
a scan stops asking (`--civitai-down` carries this to the next folders) and infers the
|
||||
rest from the files, instead of waiting for every timeout.
|
||||
|
||||
## 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 Check discovery.
|
||||
|
||||
Balanced grid thumbnails are derived, longest-edge 512 px WebP files in
|
||||
`<user folder>/anomalous/cache/card_thumbnails` (not ComfyUI's temp folder, which
|
||||
ComfyUI empties on every start). The cache is keyed by source real path and
|
||||
physical signature and capped at 256 MiB with oldest-accessed eviction; the
|
||||
settings page reads its size (`GET /anomalous/card_cache`) and empties it
|
||||
(`POST /anomalous/card_cache/clear`). A video cover's card image is its first frame
|
||||
(`variant=poster`, decoded with PyAV, which ComfyUI ships); the card shows it until
|
||||
the video plays. Listing a model type queues the covers without a card image for one
|
||||
background thread, one decode at a time, at most 512 waiting. 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 (a video without a readable frame has no poster).
|
||||
|
||||
## 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.
|
||||
- Model files are `MODEL_EXTENSIONS` in `api/model_constants.py` (`.safetensors`,
|
||||
`.ckpt`, `.pt`, `.pth`, `.bin`, `.sft`, `.gguf`); listing, counting, search and
|
||||
lookups test names with `is_model_file()`, without case. They 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,342 @@
|
||||
# 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 the Workflows page's top bar (⇅) through `window.AMB_WorkflowShare.showUnifiedModal()`. Both directions
|
||||
are enabled by explicit product decision. It is independent of the paused Recipe
|
||||
package import/export. 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,
|
||||
including `window.anomalous_open_voice(character)`, which the Anomalous_TTS node's
|
||||
character menu calls to open one character's voice card (`browser.openVoice`; the
|
||||
node pack documents it in its `docs/INTERFACE.md` §6);
|
||||
`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. The unsaved
|
||||
default position is defined in CSS outside the sidebar dock; `browser_entry.js`
|
||||
drags with pointer capture and only persists real drags, and `entry_controls.js`
|
||||
validates and clamps saved coordinates (`clampFloatingTriggerPosition`). Resetting
|
||||
the position clears the saved coordinates so the CSS default applies again.
|
||||
|
||||
Web modules are imported by plain relative paths. Do not add `?v=` query strings to
|
||||
module imports: a different URL is a second module instance with its own state.
|
||||
Freshness comes from the `Cache-Control: no-cache` header on this plugin's files.
|
||||
|
||||
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 window and the model folder list; the left
|
||||
icon rail (`ui_shell_rail.js`), page navigation and the list column's open state
|
||||
(`ui_shell_nav.js`), the home page (`ui_home.js`) and the window frame
|
||||
(`ui_shell_frame.js`) are their own modules. Every page change goes through
|
||||
`owner.goTo(page)`. `ui_settings_hub.js` owns the display preferences behind
|
||||
`owner.displayPrefs`, which the settings page (`ui_settings_page.js`) changes,
|
||||
while `ui_rail_tools.js` owns the rail's tool buttons.
|
||||
The Prompt Studio shortcut (its command and setting ids still say "materials") is a
|
||||
native ComfyUI command/keybinding; a deferred window-key fallback invokes the same
|
||||
command path only when the host did not open the studio, so handled shortcuts are not executed twice.
|
||||
`ui_browser_navigation.js` owns shared panel hiding, recoverable detail cleanup,
|
||||
and workspace return. `ui_scan_page.js` owns the scan page and `scan_runner.js` scan launch and polling,
|
||||
`ui_folder_manager.js` is the settings page's Model folders view (grouping mode,
|
||||
visibility and order, saved on each change). 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 page, 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_card.js` builds each output's card; its star and the workbench's Keep open
|
||||
`ui_keep_menu.js`, which keeps the image as a Workflow Recipe, a combo or its prompts
|
||||
(`image_keep.js`; `GET /anomalous/kept_images` says what each image was kept as).
|
||||
`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: Combos (搭配, formerly Prompt Notes) and Workflow Recipes. The combo list, workspace/persistence, editing, and canvas use are separated
|
||||
across `ui_combos.js`, `ui_notebooks.js`, `ui_notebook_editor.js`, and
|
||||
`notebook_canvas.js`; Put on canvas lays a combo out as a new wired group of nodes (Esc while it
|
||||
follows the pointer removes it; the activity log holds back while `owner.placingCombo` is set) and
|
||||
never changes nodes already on the canvas (values of an existing node are changed in
|
||||
Current node).
|
||||
- Prompts: the rail entry opens Prompt Studio (below); there is no Material Library page.
|
||||
Saved node values are listed, applied and deleted in Current node.
|
||||
- Current node (Node Assistant)/Model Check: selected-node actions, saved parameters, and
|
||||
missing-model recovery. `model_check.js` decides each workflow model's state and
|
||||
applies fixes; `ui_doctor.js` (the page) and `ui_doctor_banner.js` (the bar shown
|
||||
after a workflow opens, unless its "Don't show again" or Settings → Workflows turned the
|
||||
check on opening off) only render it and call it on a press.
|
||||
`ui_node_assistant.js` owns the current-node panel and model history, `ui_node_model_picker.js`
|
||||
owns the native-widget model replacer, and `ui_node_parameters.js` with
|
||||
`node_parameter_sets.js` own the panel's one list of saved values for the node type;
|
||||
`ui_apply_receipt.js` is the receipt with Undo after a panel writes to a node.
|
||||
|
||||
`ui_dom.js` owns generic `text` and `jsonResponse` helpers.
|
||||
`material_inspector.js` holds the image workbench's metadata helpers and
|
||||
node-parameter rendering. See `material-library.md` for the material files' 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 `Shift + B` browser binding (Ctrl + Shift + M before v1.58.2: Chrome
|
||||
and Edge take it for switching profiles) and the
|
||||
`Ctrl + Shift + L` Prompt Studio 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 Sources view (`ui_model_sources.js`, data in `model_source_links.js`) 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 Sources view'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` keeps the local hash cache and 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 edits prompts where they are: the prompt boxes of the prompt node last
|
||||
selected on the canvas, or, with no such node, a positive and a negative draft
|
||||
(`owner.promptStudioDraft`, kept while the page stays open). It opens from the rail's
|
||||
Prompts entry, the Prompt Studio shortcut and Current node's "Edit in Prompt Studio";
|
||||
the browser folds away while it is open and comes back when it closes (not when
|
||||
another studio replaces it). There is no assembly board, block draft or plan loading.
|
||||
|
||||
The target (`ui_prompt_target.js`) is polled every 400 ms. Selecting another node with
|
||||
prompt boxes switches to it; empty canvas or a node without boxes keeps the current
|
||||
one; the ✕ beside the node's name switches to the draft; a node that leaves the open graph drops back to the
|
||||
draft. Each change is one `applyNodeMaterialValues` write (a Ctrl+Z step). The studio's
|
||||
Undo takes its own writes back newest first and stops with `materialUndoChanged` when a
|
||||
box changed elsewhere; switching target clears it. Box editors re-read their widget on
|
||||
each poll and redraw unless a tag is being edited, a translation is pending or the text
|
||||
view has focus.
|
||||
|
||||
`prompt_tags.js` splits a box on top-level commas and line breaks and keeps each
|
||||
separator, so editing one tag leaves the rest of the text, line breaks included, as it
|
||||
was; a weight is `(tag:w)`. Inserting skips tags the box has already (same words, any
|
||||
weight). Typed or edited text with letters that are not plain English (`needsEnglish`:
|
||||
Chinese, kana, hangul, Cyrillic, accented Latin) is translated to English before it is
|
||||
written; a box that holds such tags offers to translate them, a translation the box
|
||||
has already removes the tag instead, and the result is written only if the box still
|
||||
holds the text the request was made from. Meanings (`prompt_gloss.js`) are shown in the
|
||||
language picked in the top bar (one of `GLOSS_LANGUAGES`, none by default, remembered in
|
||||
local storage with the view; an older "Chinese on" setting reads as Simplified Chinese).
|
||||
They are asked for in batches, kept for the session per language, and a failed lookup
|
||||
leaves that box's meanings in that language blank instead of retrying. The backend
|
||||
skips DeepL for a target it does not take and lets MyMemory detect a source that is
|
||||
neither English nor Chinese.
|
||||
|
||||
The cards are three built-in ones and the saved prompts (prompt-kind material files,
|
||||
`prompt_material_source.js`). The saved list is asked for on open, every 30 seconds
|
||||
while the page is visible (scheduled after the previous request), on focus/visibility
|
||||
and after a save; a newer request cancels the older one and a failed one keeps the
|
||||
current cards. List summaries are not prompt bodies: a prompt's text is read through
|
||||
`material_prompt_data.js` once per filename and timestamp, since a saved prompt's text
|
||||
never changes. A card's role decides its box: a positive card never goes into a
|
||||
negative box or the reverse; boxes of unknown or both roles take either. A saved
|
||||
card's preview renames it (`update_material`, tags kept) or deletes it
|
||||
(`delete_material`, to the Recycle Bin). A card, or a box's handle with the box's text
|
||||
at drag start, drags onto the canvas (`prompt_card_drag.js`, one undo step); over the
|
||||
drawer the drag passes through to the boxes.
|
||||
|
||||
| Module | Owned state and responsibilities |
|
||||
| --- | --- |
|
||||
| `ui_prompt_composer.js` | Active drawer, docking width/side, Esc, trigger visibility and reopening the browser |
|
||||
| `ui_prompt_workbench.js` | Top bar, view and meanings switches (remembered), saving a box as a card |
|
||||
| `ui_prompt_target.js` | Which node or draft is edited, the poll, node/draft writes and the studio's Undo |
|
||||
| `ui_prompt_box_editor.js` | One box: tags or text, selection, weight, edit, remove, reorder, drops, typing, translation |
|
||||
| `ui_prompt_source_deck.js` | Built-in and saved cards, search, new-card form, rename/delete and the sync request |
|
||||
| `ui_prompt_card_popover.js` | Card preview: hover corridor, pin, copy/add/rename/delete actions |
|
||||
| `prompt_card_drag.js` | A card or box dragged onto the canvas: box fill or new prompt node, passing through the drawer |
|
||||
| `prompt_tags.js` | Pure tag split/join, weights, insert/remove/replace/move keeping separators |
|
||||
| `prompt_gloss.js` | Meaning languages, session cache and batched lookup of tags' meanings |
|
||||
| `prompt_material_source.js` | Saved prompts as cards (paginated list, text read once) and saving a card |
|
||||
| `prompt_composition.js` | Starter cards (`PROMPT_PRESETS`, also the combo group's negative), composing a saved plan's text, prompt titles and categories; no DOM |
|
||||
| `ui_lifecycle.js` | View-scoped listeners, AbortSignal, cleanup callbacks and resizing |
|
||||
|
||||
Every close route (Close, Esc outside a text field, replacement) disposes the same view
|
||||
scope: the poll and sync timers, requests, translation and text timers, listeners and
|
||||
resize handlers. Esc in a studio text field only leaves the field, and Esc with a pinned
|
||||
card preview leaves the studio open. Closing during resize releases move/up listeners
|
||||
and restores body cursor and selection styles. A save already sent to the server may
|
||||
still complete, but must not reopen or repaint a disposed view.
|
||||
|
||||
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.
|
||||
|
||||
Colours come from the tokens in `00-foundation-models.css`, which both themes define:
|
||||
surfaces (`--amb-bg-page`, `-panel`, `-card`, `-card-hover`, `-input`), text
|
||||
(`--amb-text-main`, `-soft`, `-muted`, `-dim`), borders (`--amb-border`, `-strong`,
|
||||
`-hover`), the primary button (`--amb-btn-primary-*`) and one accent, `--amb-link`, for
|
||||
links, selection and focus; its tints are `color-mix(in srgb, var(--amb-link) N%,
|
||||
transparent)`. CSS injected from JS and inline styles use the same tokens. Literal
|
||||
colours stay only where the colour is the meaning: status (red, green, amber), prompt
|
||||
roles, voice emotions, canvas node colours and translucent shadows or overlays.
|
||||
|
||||
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 theme overrides in `08-theme-gallery-overrides.css` predate the tokens; many are
|
||||
now redundant and can go as the rules they override are touched.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/prompt_tags.mjs` covers splitting, joining, weights and tag edits keeping
|
||||
separators. 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,170 @@
|
||||
# Saved prompts and node values (material files)
|
||||
|
||||
Material files hold what the user keeps for reuse that is not a whole workflow or a
|
||||
combo: saved prompts and saved node values. There is no Material Library page any
|
||||
more; each kind shows where it is used:
|
||||
|
||||
- Prompt kinds (`prompt_plan`, older `prompt_text` / `prompt_note_bundle`, and
|
||||
selections made only of prompt nodes) are Prompt Studio's library cards
|
||||
(`prompt_material_source.js`), renamed, deleted and dragged onto the canvas there.
|
||||
- Value kinds (`node_parameter_selection`, `image_node_selection`,
|
||||
`recipe_parameter_selection`) are listed per node type in Current node
|
||||
(`node_parameter_sets.js`, with the recipes' own parameter sets), applied and deleted there.
|
||||
- `image_workflow_snapshot` records are whole workflows kept before recipes took over;
|
||||
the Workflows page offers to move them to recipes. Their node blocks still count as
|
||||
saved values for Current node.
|
||||
|
||||
Whole workflows are Workflow Recipes; a main model, LoRAs and a prompt kept together
|
||||
are combos (`anomalous_notebooks`). Neither is copied into material files.
|
||||
|
||||
## 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;
|
||||
`api/node_material.py` saves one canvas node's current values. 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 image-inspection route returns the validated
|
||||
workflow plus summary node blocks for the image workbench, which keeps exact metadata
|
||||
in a small 16-entry in-memory LRU. 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. Callers receive
|
||||
independent summary copies.
|
||||
|
||||
`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 available `tags`; callers without page/limit get every summary. `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` (the default) is every category
|
||||
but `workflow`. A snapshot moved to a recipe (`mark_material_moved` records
|
||||
`moved_to_recipe`; the file stays) is listed in no category. Name/tag edits use
|
||||
`update_material` and atomically replace the record. Tags are trimmed, deduplicated
|
||||
without case sensitivity, and limited to 20 tags of 60 characters each.
|
||||
|
||||
Save, edit, and delete serialize their writes within the server process. An image
|
||||
save compares the source PNG SHA-256, material kind, and selected node IDs, and a
|
||||
prompt plan or node values compare their content signature, against existing
|
||||
summaries. A duplicate returns HTTP 409 with `status: duplicate` and the existing
|
||||
name/filename, without leaving new assets; `allow_duplicate: true` creates a copy.
|
||||
Deleting moves the record and its assets to the Recycle Bin.
|
||||
|
||||
The route family is:
|
||||
|
||||
- `POST /anomalous/inspect_image_material`
|
||||
- `POST /anomalous/save_image_material`
|
||||
- `POST /anomalous/save_prompt_plan`
|
||||
- `POST /anomalous/save_node_material`
|
||||
- `POST /anomalous/mark_material_moved`
|
||||
- `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`
|
||||
|
||||
`material_full?include_workflow=0` returns metadata and scoped node blocks (with
|
||||
`workflow_hashes` restricted to them) and no complete workflow; `include_workflow=1`
|
||||
returns the original workflow of a complete snapshot. Selected-node records never
|
||||
return a full workflow and reject explicit workflow requests with HTTP 403.
|
||||
|
||||
## Kinds
|
||||
|
||||
Records declare `schema_version`, `kind`, and `capabilities`.
|
||||
|
||||
- `image_workflow_snapshot`: a generated PNG's complete UI workflow (no longer
|
||||
created; kept images are recipes).
|
||||
- `image_node_selection`: some nodes of an image's workflow, saved from the image
|
||||
workbench (a node card, checked cards, or the generation settings). The workflow
|
||||
stays as provenance; list, count and lookup expose only the selected blocks.
|
||||
- `recipe_parameter_selection`: nodes of a recipe or one of its parameter sets, saved
|
||||
before Current node listed recipe sets itself (no longer created). No image asset;
|
||||
only `apply_node_parameters`.
|
||||
- `node_parameter_selection`: one canvas node's values, saved from Current node.
|
||||
- `prompt_plan`: an image-free prompt with `compose_prompt` (below).
|
||||
- `prompt_text` / `prompt_note_bundle`: prompts copied from Prompt Notes before combos;
|
||||
read as prompt cards (`note.promptEn`).
|
||||
|
||||
Prompt roles in image selections are inferred from workflow topology first, with
|
||||
node-title hints as a fallback. The image workbench shows the inferred role beside
|
||||
every prompt node and allows a positive, negative, shared, unknown, or ignored
|
||||
override, which travels into the saved record but never writes back to a Recipe.
|
||||
|
||||
## Keeping an output image
|
||||
|
||||
The gallery's star and the image workbench's Keep (`ui_keep_menu.js`) keep an output PNG
|
||||
as one of three things, each recording the image as its source: the whole workflow as a
|
||||
Workflow Recipe (`recipe_save.js`, `source_image`), its main model, LoRAs and positive
|
||||
prompt as a combo (`image_keep.js`; models are looked up among this computer's files with
|
||||
`/anomalous/resolve_paths_to_previews`, missing ones are left out; the notebook file keeps
|
||||
`source_image`), or its positive and negative prompts as a `prompt_plan` material
|
||||
(`/anomalous/save_prompt_plan` stores `source.image`; the same plan saved again answers 409
|
||||
with the existing one). The image's workflow is laid on a detached graph and read with
|
||||
`extractRecipeMetadata`, so prompts follow the wiring as in a recipe. `GET
|
||||
/anomalous/kept_images` (`api/kept_images.py`) lists each image's recipe, combo and prompt;
|
||||
the star is filled when any exists, and a kept row opens it or removes it: what was kept goes to
|
||||
the Recycle Bin through its own delete route (`delete_recipe`, `delete_notebook`,
|
||||
`delete_material`) after a confirmation, and the star follows.
|
||||
|
||||
## Prompt plans
|
||||
|
||||
`prompt_plan` has no synthetic workflow. `POST save_prompt_plan` accepts a name, tags,
|
||||
an optional `source_image`, and `plan`: `parts` is an ordered list of up to 100
|
||||
records with `name` (up to 120 characters), `category`, `role`, `enabled`, `positive`,
|
||||
and `negative`; top-level `positive` and `negative` hold the composed text. The plan
|
||||
is bounded to 2 MiB; unknown fields are discarded. `material_prompt_data.js` reads
|
||||
the text Prompt Studio shows: plans compose their parts, notes use `note.promptEn`,
|
||||
and workflow selections use `prompt_groups`.
|
||||
|
||||
## Applying saved values
|
||||
|
||||
`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. Current node writes only the values that
|
||||
differ (never seeds, model files or choices this computer lacks) and shows the
|
||||
receipt with Undo (`ui_apply_receipt.js`); Undo restores values only while the same
|
||||
live node still holds the applied state. Material storage preserves a workflow's
|
||||
`extra.anomalous_hashes`, but applying saved values leaves model files and their hash
|
||||
evidence as they are; Model Check remains the authority on whether a missing model
|
||||
may be repaired, and a saved name, preview, path or size is never treated as identity.
|
||||
|
||||
## Dragging prompts onto the canvas
|
||||
|
||||
Prompt Studio's cards drag onto the canvas (`prompt_card_drag.js` through
|
||||
`material_drag.js`). Only an active, same-page drag is trusted; transfer data is a
|
||||
marker, not an external mutation command. Over the studio drawer the drag passes
|
||||
through to the studio's prompt boxes. Live node lookup uses ComfyUI canvas coordinate
|
||||
conversion, canvas bounds and graph hit-testing; DOM-widget surfaces are accepted,
|
||||
and the graph/canvas identities captured at drag start are checked again at drop.
|
||||
|
||||
While a prompt is dragged (`prompt_drop.js`), every prompt box on the canvas is
|
||||
outlined in its role's colour (`prompt_boxes.js`: a multiline STRING input by the
|
||||
node's definition; its role is its own name or the wiring to a sampler). The box
|
||||
under the pointer is the target (a node with one box needs no aim), the hint names
|
||||
the node and box, and the text a release would write is laid over the box
|
||||
(`.anomalous-prompt-preview`, never in the widget's value). A card fills the box with
|
||||
its text as one undo step; negative text never goes into a positive box. Released on
|
||||
empty canvas, a card becomes a `CLIPTextEncode` node of its role with its text. Empty
|
||||
or refusing targets and cancelled drags do not change the graph; nothing queues
|
||||
generation.
|
||||
@@ -0,0 +1,161 @@
|
||||
# Model Identity and Scanning
|
||||
|
||||
Read this document for Model Check, workflow provenance, missing-model recovery,
|
||||
hash caches, Civitai metadata resolution, or model scanning.
|
||||
|
||||
## Identity boundary
|
||||
|
||||
Model Check 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 shows it, but it cannot redirect the node until the user takes 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 never establish identity. 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.
|
||||
One exception, for ordinary models only: when the workflow carries no record at
|
||||
all (a workflow saved without the plugin), the single file of the same name
|
||||
among the widget's own choices is offered as a candidate (`via: 'name'`); a
|
||||
size-only candidate whose name also matches is shown as likely (`via:
|
||||
'name-size'`). Both take a press on their row and never count among the files
|
||||
put in at once. Several files of that name leave the model ambiguous.
|
||||
|
||||
Foundation components—`vae`, `vae_approx`, `clip`, `text_encoders`, and
|
||||
`clip_vision`—are hash-only automatic-recovery categories. Byte size alone
|
||||
cannot automatically repair them, and they are never offered by name. 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 Check confidence boundary, not a scanner-support boundary. The
|
||||
scanner 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 Check 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.
|
||||
The same pass writes `extraObj.anomalous_model_sources`: links the workflow keeps
|
||||
win; a model present here without one gets its own information's link (the
|
||||
editor's first, else Civitai's), marked `auto` so a newer one replaces it; links
|
||||
for names no node holds any more are dropped. The live graph is never changed.
|
||||
The share-code export may leave the links out.
|
||||
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.
|
||||
|
||||
## Scanning
|
||||
|
||||
A scan (the scan page) 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 through the node's widget hooks, which also clears ComfyUI's
|
||||
missing-model mark.
|
||||
|
||||
Checking never changes the workflow (`model_check.js`). Every opened workflow is
|
||||
checked without reloading ComfyUI's model lists; when models are missing, a bar
|
||||
over the canvas says how many and how many hash matches can be put in. Only a
|
||||
press puts them in: the bar's or the doctor page's for hash matches, the page's
|
||||
per-row button for a size-only candidate. A scan with "fix the open workflow"
|
||||
on puts hash matches in when it ends.
|
||||
|
||||
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,144 @@
|
||||
# Workflow Recipe Model Lifecycle
|
||||
|
||||
Read this document before changing Workflow Recipe model cards, local matching,
|
||||
partial-canvas append, package export, or Model Check 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 Check 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 Check 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 Check 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 Check:
|
||||
|
||||
- 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 Check evidence and candidate policy;
|
||||
- `web/modules/recipe_identity.js`: model-reference adapters for presentation:
|
||||
native loaders plus `ALL_IN_ONE_LOADER_SPECS` (verified serialized layouts),
|
||||
mirrored by `_model_reference_specs` in `api/recipe_schema.py`. Add a loader
|
||||
to both tables with its real widget order; never infer a category from a
|
||||
file name, and skip placeholder values such as `None` / `Baked VAE`;
|
||||
- `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,230 @@
|
||||
# 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 on the Workflows page's top bar (⇅).
|
||||
|
||||
## Product and data model
|
||||
|
||||
Workspace contains Combos (formerly Prompt Notes) and Workflow Recipes.
|
||||
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_models.js`
|
||||
lists the models with previews and whether each is here under its saved name. 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; the saved path is a secondary line and the
|
||||
detail shows no hashes (Model Check shows that evidence). A preview or exact path can locate current-machine presentation
|
||||
only after the reference is already understood; it cannot establish identity.
|
||||
|
||||
The recipe detail does not match or replace models. A model not found under its
|
||||
saved name is listed as missing, and opening the workflow hands it to Model Check
|
||||
(model-resolution.md), which finds renamed or moved files on the canvas; saving
|
||||
the recipe again keeps the change, with the previous version archived.
|
||||
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,53 @@
|
||||
# 更新引导维护契约
|
||||
|
||||
更新引导只解释明显变化,不承担功能开关、数据迁移或执行用户操作。
|
||||
修改前阅读根目录 [AGENTS.md](../../AGENTS.md)。
|
||||
|
||||
## 内容与实现分工
|
||||
|
||||
| 文件 | 责任 |
|
||||
| --- | --- |
|
||||
| `web/modules/update_guide_data.js` | 当前引导的稳定 ID、步骤顺序、图标和文案键;也提供配置校验 |
|
||||
| `web/modules/locales.js` | 每个步骤及按钮的中英文文案,均按纯文本展示 |
|
||||
| `web/modules/ui_update_guide.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. 修改现有样式规则,不在文件末尾追加覆盖包。没有明确产品决策,不增加强制阅读、自动跳转、网络请求或额外追踪。
|
||||
|
||||
## 音频页使用说明
|
||||
|
||||
- `AUDIO_USAGE_GUIDE`(同在 `update_guide_data.js`)是音频页的使用说明,不是更新公告。音频域激活时,右上角 **!** 打开它;图像域照旧打开 `CURRENT_UPDATE_GUIDE`。
|
||||
- 可选字段:`titleKey` 替换对话框标题;`tour: false` 不显示遮罩导览入口,`tour: [...]` 用这份说明自己的导览步骤(音频页用 `AUDIO_TOUR_STEPS`),不写则是图像界面的导览。导览步骤的目标不在屏幕上时自动跳过。
|
||||
- 内容跟着功能改,ID 保持 `audio-studio-usage`。完整说明在 `docs/guides/audio-studio.md`,两边要一致。
|
||||
|
||||
## 生命周期与记录
|
||||
|
||||
- 面板右上角常驻感叹号按钮(`#anomalous-update-notice-btn`),提供完整的用途提示和 accessible name。只有用户点击该按钮或帮助页入口才打开引导;初始化、打开插件、版本变化都不能触发弹窗。
|
||||
- 使用原生 `dialog` 的顶层显示和焦点约束,关闭时尽可能恢复原焦点。
|
||||
- 完成、跳过、×、Esc、点击空白关闭会记录已读,上方入口仍然保留;关闭父浏览器仅清理,不将未读引导标记为已读。入口不需要全局事件或延时隐藏。
|
||||
- 持久化键为 `anomalous_update_guide_seen:<id>`,值为 `1`。只保存本地已读状态,不发送到服务端。
|
||||
- 存储不可用时,当前页面会话仍记住关闭状态;刷新后仍必须手动点击才能显示。
|
||||
- 同一时刻只有一个引导。重复调用不叠加弹窗;所有关闭路径通过 `closeUpdateGuide` 释放资源。
|
||||
- 上方按钮和帮助页的“查看更新引导”均使用 `force: true`,允许重看已确认版本。
|
||||
- 引导不自动点击对应功能,也不修改画布、模型、素材、导入导出限制。
|
||||
|
||||
## 验证
|
||||
|
||||
在插件根目录运行:
|
||||
|
||||
```powershell
|
||||
node --experimental-vm-modules tests/update_guide.mjs
|
||||
```
|
||||
|
||||
`update_guide.mjs` 加载真实模块,检查双语配置、版本记忆、重看、翻页、关闭清理、
|
||||
父窗口关闭、存储失败及扫描图标替换后的标签。它不能代替实际浏览器的布局与焦点验证。
|
||||
只有同时修改工坊生命周期或导入导出行为时,才追加对应测试;纯引导样式不运行无关测试。
|
||||
@@ -0,0 +1,151 @@
|
||||
# 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.
|
||||
|
||||
## AD-016 — The audio page manages one speech engine
|
||||
|
||||
The audio page is built around GPT-SoVITS through the separate Anomalous_TTS
|
||||
node pack: its characters, emotions, pronunciation and sampling defaults live in
|
||||
the node's own settings, and the Script Director can run a script without the
|
||||
canvas. The F5-TTS voice library (files under `input/F5-TTS`, upload, romanization,
|
||||
Load Audio drags) was removed rather than kept as a parallel engine, so the page
|
||||
teaches one set of rules. A zero-shot voice (one clip, no trained model) belongs
|
||||
in Anomalous_TTS as a character kind, not as a second engine here.
|
||||
|
||||
## AD-017 — The browser is a rail of pages with a home page
|
||||
|
||||
Navigation is one narrow icon rail: a home page, the image pages (models, gallery,
|
||||
workflows, materials), the audio pages (voices, audio gallery), the canvas tools and
|
||||
settings. The rail replaces the header tabs, the image/audio toggle and the tool bar
|
||||
under the folder list, so the list column holds only a list and can close without
|
||||
hiding any command. Each page decides whether it has a list; the open state is
|
||||
remembered per page and narrow windows start with it closed. The home page offers
|
||||
tasks rather than a manual: each card opens its page, and a first visit lands there.
|
||||
AMB stays the chore layer around ComfyUI (AD-016 keeps one engine per page); a new
|
||||
page earns a rail entry only when it is a place users return to, not a one-off tool.
|
||||
|
||||
## AD-018 — The activity log records what Anomalous changed, without undo for now
|
||||
|
||||
Users need to see what the plugin did to their canvas and files. File changes are
|
||||
recorded on the server, after a write request succeeds, from one table of routes,
|
||||
so a new write route is logged by adding a line there. Canvas changes are recorded
|
||||
without each action reporting itself: a press inside Anomalous takes a snapshot, and
|
||||
the difference is logged when the user next presses or types outside it, so the
|
||||
user's own edits are never attributed to the plugin. Opening another workflow is
|
||||
logged as that, not as removed nodes. The log is bounded and never fails the action
|
||||
it records. Undo comes later and only for changes that can be reversed safely
|
||||
(canvas values, renames); deletions go through their own confirmations instead.
|
||||
@@ -0,0 +1,226 @@
|
||||
# 音频工作台使用说明
|
||||
|
||||
插件里也能看简版:进入音频页后,点右上角的 **!**。
|
||||
|
||||
---
|
||||
|
||||
## 0. 需要什么
|
||||
|
||||
音频页用 GPT-SoVITS 给角色配音,需要节点包 **Anomalous_TTS**。
|
||||
|
||||
- **没装时**,音频页中间会写它能做什么、怎么装。页面不会弹窗打扰你。
|
||||
- **装好后**,重启 ComfyUI,再点“刷新音色”就能看到角色。
|
||||
- **没装也不影响插件的其他功能。**
|
||||
|
||||
### 左边的侧栏
|
||||
|
||||
- **分组**:角色按语言分组(中文、日语、英语、未标语言)。点组名可以收起,下次打开还是收着的。只有一组时不分组。
|
||||
- **展开角色**:点角色名左边的小箭头,下面会列出它的每条声音(主声音、`{开心}` 这样的情绪)。点一条就在右边播放,再点一下停。
|
||||
- **拖到节点上**:侧栏里的角色可以直接拖到节点上,和拖右边卡片的标题栏一样。
|
||||
- **小红点**:角色名后面有小红点,说明这个角色有问题(读取失败,或者没有能当主声音的音频)。鼠标停在点上能看到原因。
|
||||
- **搜索**:角色有 6 个以上时,顶上会出现搜索框,可以搜角色名、别名或情绪名。
|
||||
- **拖文件**:把音频、模型文件拖到侧栏里的某个角色上,会打开“添加文件”;拖到空白处就是导入新角色。
|
||||
|
||||
还没有角色的话,先看第 4 节“导入角色”。
|
||||
|
||||
---
|
||||
|
||||
## 1. 角色卡片怎么用
|
||||
|
||||
每个角色一张卡片:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ ⠿ 🎙 Arona 编辑情绪 读音 添加文件 3 角色音色预设 │ ← 标题栏:代表“整个角色”
|
||||
├───────────────────────────────────────────────┤
|
||||
│ ▶ MAIN “それぞれの学園…” [{main}] │ ← 每一行:一条参考音频
|
||||
│ ▶ ANGRY “相手の雰囲気が…” [{angry}] │
|
||||
│ ▶ HAPPY “うん、よし!…” [{happy}] │
|
||||
└───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 操作 | 结果 |
|
||||
|---|---|
|
||||
| 点 ▶ | 试听这一条 |
|
||||
| 点右边的 **复制 `{标签}`** 按钮(角色设了情绪才有) | 复制标签,粘贴到台词里 |
|
||||
| **拖标题栏** → 放到 **角色语音 (GPT-SoVITS)** 节点 | 节点的“角色”设为这个角色 |
|
||||
| **编辑情绪** / **读音** / **添加文件** | 见第 2、4 节 |
|
||||
|
||||
情绪靠台词里的 `{happy}` 这种标签来切换,所以拖的是整个角色,不是某一条音频。
|
||||
|
||||
### 拖动时看提示
|
||||
|
||||
拖动时,鼠标旁边有一个提示框:
|
||||
|
||||
- **白色**:`松手:GPT-SoVITS 节点的角色设为 Arona`。可以松手。
|
||||
- **黄色**:拖到了不合适的节点,提示会写原因和正确做法。这时候松手什么也不会改。
|
||||
|
||||
目前只接收 **角色语音 (GPT-SoVITS)** 节点,其他节点一律不接收。这是故意的:插件不靠猜,避免写错地方。
|
||||
|
||||
## 2. 配音:一段话多种情绪
|
||||
|
||||
1. 在 **角色语音** 页顶部从 **角色** 切到 **配音**,或者在角色卡片上点 **配音**,直接带着这个角色过去。谁来念,在左边列表里点角色或在上方选。
|
||||
2. 把整段台词粘贴进去,点 **分成句子**。每句话会变成一张卡片。
|
||||
3. 每张卡片点一下情绪(主声音 / happy / angry……),旁边的小喇叭能听这个情绪原本的参考声音。角色只有主声音时不显示情绪按钮。也可以直接改字;鼠标移到卡片上会出现 ✂(在光标处拆开)、↧(和下一句合并)、×(删除)。
|
||||
|
||||
### 直接生成,不用画布
|
||||
|
||||
页面够宽时分成两栏:左边是台词卡片,右边是 **生成语音** 和 **念法**。窗口窄或者浏览器停靠在侧边时,生成按钮和设置放在台词下面。两种情况都只有台词这一块会滚动,句子再多,按钮也一直看得见。
|
||||
|
||||
1. **念法** 一直显示:语言(自动 / 中 / 日 / 英)点一下就选,语速用滑块。第一次用的是这个角色的默认值。
|
||||
下面的 **细调(一般不用动)** 收着:候选数量 `top_k`、候选范围 `top_p`、随机程度 `temperature`、防重复 `repetition_penalty`,鼠标停在输入框上有说明。调过时那一行后面会写“调过”。
|
||||
2. 某次效果满意,点 **把这套设置存为这个角色的默认**:语言、语速和细调都会记住,下次打开这个角色直接用。种子不存,它只对当时那段台词有意义。
|
||||
3. 点 **生成语音**。插件直接交给 ComfyUI 生成,画布上的工作流一个不动;ComfyUI 正在出图时会先排队。
|
||||
4. 生成完自动播放,写着“生成好了,用时几秒”。文件存在 `output/audio/角色名/`;点 **已存进音频库 ›** 直接去音频库。
|
||||
5. 不满意:
|
||||
- 某一句不好,点那张卡片上的 **↻**,只把这一句换一版,其他句子不变(直接用缓存,很快)。
|
||||
- 整段都不好,点 **整段换一版**,换一个种子全部重新生成。
|
||||
|
||||
需要 Anomalous_TTS 接口版本 11 以上;旧版本时这里会提示更新,仍然可以推送到节点、在画布上运行。
|
||||
|
||||
### 放进自己的工作流
|
||||
|
||||
生成按钮下面的 **放进自己的工作流** 一栏,把剧本写进画布上的 **角色语音 (GPT-SoVITS)** 节点:
|
||||
|
||||
- 点 **推送到节点**:写进选中的节点。画布上只有一个这种节点时,不用选。
|
||||
- 按住 **拖到节点**,把整段剧本拖到某个节点上。
|
||||
- 点 **复制剧本**:复制带标签的台词,例如
|
||||
```
|
||||
{main} 老师,早上好!
|
||||
{happy} 今天也要加油哦!
|
||||
```
|
||||
|
||||
推送和拖动都会同时设置“角色”和“台词”,写错了可以按 Ctrl+Z 撤回。节点里原来有台词时,会先问要不要覆盖。
|
||||
|
||||
### 某个词总是读错
|
||||
|
||||
角色卡片上的 **读音** 打开这个角色的读音表:左边写剧本里的字,右边写让它实际读的字,例如 `C站 → 西站`、`粘进 → 沾进`。剧本和字幕照常写,只影响念出来的声音。
|
||||
|
||||
- 每一行的 **现在** 听这个角色现在怎么读左边的字,**改后** 听右边的字,对比着改。试听不会存进音频库。
|
||||
- 日语模型说中文时特别有用:它发不好的音(选、全这类 ü,模型、行这类后鼻音)换成好读的同音字,或在前面加个字;英文字母写成读音相近的汉字。
|
||||
- 长的先匹配,只替换一遍。只对这个角色生效。
|
||||
|
||||
## 3. 找回结果
|
||||
|
||||
- 生成的音频在图标栏的 **音频库**:
|
||||
- 每条会显示台词、声音和种子,可以试听、按台词搜索、删除。
|
||||
- 只在预览里出现、还没保存的临时结果,列在“未保存的预览”里,点 **保存** 就能存到 `output/audio/`。
|
||||
|
||||
## 4. 导入角色和设置
|
||||
|
||||
### 设置
|
||||
|
||||
工具栏上有 **导入角色**。还没有角色时,中间是一块空的导入区,直接把文件拖进去或点 **+ 导入角色**。
|
||||
|
||||
存放位置、底模这些设置在左边侧栏的最下面:**GPT-SoVITS 设置**,点开是一个窗口。底模缺了不会挡路(第一次生成时会自动下载),所以只在这个入口上放一个小黄点:已有角色用到的底模还没下载时才出现。窗口里第一行写着缺几个、多大,可以 **现在下载**,也可以点 **不再提醒**。以后导入了要用新底模的角色(比如第一个中文角色),小黄点会再出现。缺 Python 包时是小红点,不能关掉。下载的进度也写在这个入口下面。
|
||||
|
||||
窗口里有:
|
||||
|
||||
- **角色存放位置**:导入的角色都放在这里,默认 `ComfyUI/models/gpt_sovits/`。
|
||||
- **其他位置**:设置文件里写的其他角色文件夹,以及 `extra_model_paths.yaml` 里的文件夹。
|
||||
- **底模**:每个底模是已就绪、缺少还是下载中。可以单个 **下载** 或 **全部下载**。只说日语的角色不需要中文底模;标“可选”的缺了也能用。
|
||||
- **Python 包**:缺了哪种语言要的包,会给出安装命令,复制到命令行运行后重启 ComfyUI。
|
||||
- **设置文件**:Anomalous_TTS 的设置文件 `ComfyUI/user/anomalous_tts.json` 的位置,可以复制路径。存放位置、其他角色文件夹和已有的 GPT-SoVITS 整合包(用包里的底模,不用再下一遍)都在这个文件里改,窗口里改不了:节点不接受网页指定的本机路径,这样网页就决定不了它在哪里建、删文件夹或读哪些文件。路径写完整,改完不用重启:
|
||||
|
||||
```json
|
||||
{
|
||||
"format": 1,
|
||||
"storage": "D:/voices",
|
||||
"libraries": ["E:/old voices"],
|
||||
"pretrained": ["D:/GPT-SoVITS/GPT_SoVITS"]
|
||||
}
|
||||
```
|
||||
|
||||
要换存放位置,先把角色文件夹自己移过去,再改 `storage`。
|
||||
|
||||
导入表单里选了语言后,下面会用小字写出生成时还要用到哪些底模。
|
||||
|
||||
这些操作会读写运行 ComfyUI 那台电脑上的文件,所以只能在那台电脑上用;从别的设备打开时按钮是灰的。
|
||||
|
||||
### 导入角色(新用户从这里开始)
|
||||
|
||||
点 **导入角色**。第一次打开时会有一段遮罩引导,一步步指给你看;之后随时点窗口右上角的 **?** 再看一遍。窗口顶上写着现在在哪一步:**① 选方式 → ② 放入文件 → ③ 导入**。
|
||||
|
||||
**① 选方式**:窗口先问“你手上有什么?”
|
||||
|
||||
- **一个角色**:这个角色的 GPT 模型、SoVITS 模型和几段参考音频。会一步步带你放好。
|
||||
- **很多角色 / 整合包**:一次导入好几个角色,或者选一个 GPT-SoVITS 整合包文件夹,会按名字自动分好。
|
||||
- **给已有角色加声音**:选一个已经导入的角色,往里面加参考音频、台词文件或新模型。
|
||||
|
||||
直接把文件拖到音频页或这个窗口上,会跳过这一步,按“很多角色”处理(一个角色的文件也能正确认出来)。选错了可以点 **‹ 换一种方式**(还没放文件时才有)。
|
||||
|
||||
**② 放入文件**:每个角色一张卡片,卡片最上面是一份**待办清单**,永远高亮下一步,按钮就在那一行:
|
||||
|
||||
1. 放 GPT 模型(`.ckpt`,在整合包的 `GPT_weights` 文件夹里)
|
||||
2. 放 SoVITS 模型(`.pth`,在 `SoVITS_weights` 文件夹里)
|
||||
3. 放几段参考音频(3~10 秒、说得清楚的)
|
||||
4. 选一段当主声音:在音频列表里点 **☆**,变成 **★** 就是主声音(一般会自动选好)
|
||||
5. 给角色起个名字(一般会从模型文件名自动填好;卡片标题就是名字,可以直接改)
|
||||
|
||||
做完一项就打勾;卡片标题旁写着“还差 N 步”,全部完成变成绿色的 **可以导入**。任何时候都可以直接把文件拖进窗口,会自动放到对的地方。
|
||||
|
||||
参考音频一段一行:点前面的 **▶** 试听,再点一次(■)停止;点另一段就直接换过去,方便一段段对比着挑。上传中、还没分给角色的音频都能听。填上情绪名(例如“开心”)就成了 `{开心}`。**不在 3~10 秒的音频不会导入**(节点用不了它们),连同同名的台词文件一起收进卡片底部的“不导入”,点开能看到是哪些。一个角色音频很多时先显示前 60 段,点 **显示全部** 看其余的。台词会自动从同名 `.txt` / `.lab` 或标注文件里找;台词框是黄框表示台词取自文件名,请确认;红色虚线框表示没找到,可以粘贴,也可以把 `.txt` / `.lab` / `.list` 拖到框里。卡片底部是台词文件和 **角色说的语言**(一般自动判断就行)。
|
||||
|
||||
**很多角色 / 整合包**时:
|
||||
|
||||
- **一个文件夹一个角色**:一个文件夹里(包括它的子文件夹)只有一套 GPT + SoVITS 模型,还有音频或台词,它就是一个角色,用文件夹名当角色名。比如 `派蒙/` 里有 `model-e10.ckpt`、`model_e10.pth` 和参考音频,角色就叫“派蒙”;两个角色的模型都叫 `model` 也不会混在一起。文件夹叫 `v2`、`v4` 这类版本名时,前面加上上一级的名字(“派蒙 v2”)。
|
||||
- 模型放在整合包的 `GPT_weights*` / `SoVITS_weights*` 里,或者好几个角色的模型平铺在一起时,按模型文件名配成角色:`ALuoNa-e15.ckpt` 和 `ALuoNa_e16_s224.pth` 都算 `ALuoNa`。好几轮(e5、e10、e15)时自动用轮数最大的。
|
||||
- 其他的按所在的文件夹(同一个文件夹里的模型)、文件夹名(比如 `参考音频/ALuoNa/`)或文件名开头分给对应的角色。同一个文件夹里有 `Guang` 和 `Guang_v4` 两套模型时,归 `Guang`。
|
||||
- 没分给任何角色的 `.list` 标注文件,每个角色都放一份(节点按文件名找台词,不会找错)。
|
||||
- 同一个角色里有同名的文件(比如 `开心/01.wav` 和 `难过/01.wav`),后来的会在前面加上文件夹名(`难过_01.wav`),同名的台词文件一起改,不会丢;鼠标停在文件名上能看到原来的文件。
|
||||
- 分不出来的放在最上面一张黄色卡片里,放进角色后才上传。文件多时一个文件夹一行,可以把整个文件夹一次放到某个角色;点开这一行,也能一个一个选。分错的文件,右边也能改。
|
||||
- 不会读的文件夹:`runtime`、`pretrained_models`、`venv` 这类 Python 环境和底模文件夹;整合包里(旁边有 `runtime`、`GPT_weights*`、`webui.py` 这些)的 `GPT_SoVITS`、`logs`、`output`、`TEMP`、`tools`,里面是程序、训练中间存档和数据集切片。你自己起名叫 `output`、`GPT_SoVITS` 的文件夹照常读;你直接选中或拖进来的那个文件夹一定会读。跳过了哪些,窗口里会写出来。
|
||||
- 一次最多读 5000 个文件。
|
||||
- 角色很多时先打开一批(20 个角色,或者大约 800 个文件),其余的在“打开下一批”按钮里;这一批导入完,下一批会自动打开。这样一次拖进很大的文件夹也不会卡。
|
||||
- 一次要上传很多文件(超过 1500 个或 2 GB)时会先问你要不要继续。文件都是从浏览器上传给节点的,节点不会按路径去读你电脑上的文件。
|
||||
- 卡片收起时只有一行,点开看详情,一次只展开一张。右上角 **+ 继续添加** 可以再加文件,**+ 新角色** 手动加一个角色。
|
||||
|
||||
**③ 导入**:底部按钮写着“导入 N 个角色”,只导入“可以导入”的那些。每个角色单独导入,一个失败不影响其他的;失败的留在窗口里标红,改好再点一次。全部导入完窗口自动关闭,并滚到新角色的卡片。底部还写着会保存到哪里(想换地方,在设置里改存放位置)。
|
||||
|
||||
节点会建好 `角色名/GPT_weights/`、`SoVITS_weights/`、`参考音频/` 和设置文件。**一律复制,原文件不动**;中途出错时存放位置保持原样,不会留下半个角色。
|
||||
|
||||
给已有角色加音频或情绪:点卡片上的 **添加文件**,或者把文件拖到那张卡片(或侧栏里的角色名)上。这时放进来的文件都归这个角色,模型可以不放。已有的文件不会被覆盖,主声音也不会被换掉(除非你点了“★ 主声音”)。
|
||||
|
||||
一条一条加也不会越加越乱:
|
||||
|
||||
- 角色里已经有一模一样的文件,那一行是灰色的“角色里已有,跳过”,不会再复制一份。
|
||||
- 标注文件(`.list` 等)再放一次,只会把新的行追加进角色里已有的那份,写着“合并新台词”。
|
||||
- 只加一条音频、不带标注文件时,台词会从角色里已有的标注文件里找。
|
||||
- 只有“同名但内容不同”的文件会标红,不能导入。改个文件名,或者删掉那一行。
|
||||
|
||||
### 角色文件夹的规则
|
||||
|
||||
- 一个角色就是一个文件夹,里面至少有一个 `.ckpt`(GPT 权重)和一个 `.pth`(SoVITS 权重)。
|
||||
- 角色文件夹放在存放位置里(默认 `ComfyUI/models/gpt_sovits/`),也可以放在 `extra_model_paths.yaml` 里写的 `gpt_sovits` 目录。自己往存放位置里放整理好的角色文件夹也可以,点 **刷新音色** 就能看到。
|
||||
- 同一个角色有日配、中配两个版本时,会显示成 `阿罗娜/日配`、`阿罗娜/中配` 两个角色。
|
||||
|
||||
### 编辑情绪
|
||||
|
||||
点角色卡片上的 **编辑情绪**:
|
||||
|
||||
1. **主声音 `{main}`**:选一条 3~10 秒的参考音频。留空的话,节点会自己挑一条。
|
||||
2. **情绪**:每行填一个情绪名(例如“开心”),再从角色文件夹里选一条参考音频。输入框可以直接打字搜索文件名。
|
||||
3. **参考台词**:可以不填,节点会自己去找同名 `.txt` 或 GPT-SoVITS 的标注文件。
|
||||
4. 点 **保存**。设置会写进角色文件夹里的 `anomalous_tts.json`,由节点检查后保存。选了不存在的文件时,会提示原因,什么都不会改。
|
||||
|
||||
文件名本身带情绪的音频(例如 `Arona_Talk.开心.wav`)会被自动识别,不用再填一遍。只是扩展名的部分不算情绪,例如 `Arona_Talk.ogg.wav`、`Arona_Talk.ogg (1).ogg`。
|
||||
|
||||
角色列表会缓存一会儿,切换页面不会卡。往模型文件夹里加了角色或音频后,点 **刷新音色** 才会重新读取。
|
||||
|
||||
### 台词里的其他写法
|
||||
|
||||
- `[普拉娜]`:换人说。要写角色名或别名。
|
||||
- `[pause:1s]`:停顿。
|
||||
|
||||
## 5. 常见问题
|
||||
|
||||
| 现象 | 原因和解决 |
|
||||
|---|---|
|
||||
| 拖到节点上提示“列表里还没有 …” | ComfyUI 还没刷新节点定义。按 **R** 后再拖 |
|
||||
| 标题栏拖不动 | 这个角色没有主声音 |
|
||||
| 拖到别的语音节点被拒绝 | 目前只支持“角色语音 (GPT-SoVITS)”节点 |
|
||||
| 卡片上提示“没有找到合适的主声音” | 角色文件夹里没有 3~10 秒、找得到台词的音频。点“编辑情绪”手动选一条 |
|
||||
| 新放进去的角色或音频没出现 | 点 **刷新音色**。节点那边在 ComfyUI 里按 **R** 刷新下拉框 |
|
||||
| 侧栏里看不到“GPT-SoVITS 设置”,工具栏上没有“导入角色” | Anomalous_TTS 版本太旧(没有接口版本 3)。更新 Anomalous_TTS 后重启 ComfyUI |
|
||||
| 同名文件导入时报“有重名的文件” | Anomalous_TTS 早于接口版本 9。更新后重启 ComfyUI |
|
||||
| “导入角色”是灰的 | 页面是从别的设备打开的。导入会写文件,只能在运行 ComfyUI 的电脑上做 |
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,144 @@
|
||||
"""File SHA-256 evidence, the base model a safetensors header tells, and the model
|
||||
sidecars, shared by the standalone scanner and API.
|
||||
|
||||
A model's information comes in three layers, the user's first:
|
||||
- <model>.anomalous.json what the user set in the model editor (only the editor writes it);
|
||||
- <model>.info what a scan found: Civitai's record, or (id -1) what the file
|
||||
itself tells; <model>.civitai.info is the same from other tools;
|
||||
- the model file itself.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
|
||||
|
||||
USER_INFO_SUFFIX = ".anomalous.json"
|
||||
|
||||
|
||||
def read_json(path):
|
||||
"""A JSON object from ``path``; None when missing, unreadable or not an object."""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def scan_info_path(base):
|
||||
"""The sidecar holding a scan result: <model>.info, else another tool's <model>.civitai.info.
|
||||
A .civitai.info with only edits made in older versions (no Civitai ids) does not count,
|
||||
so editing a model's name never makes it look scanned. ``base``: the path without extension."""
|
||||
if os.path.exists(base + ".info"):
|
||||
return base + ".info"
|
||||
data = read_json(base + ".civitai.info")
|
||||
if data is not None and ("id" in data or "modelId" in data):
|
||||
return base + ".civitai.info"
|
||||
return None
|
||||
|
||||
|
||||
def sidecar_info(base):
|
||||
"""The scan result (see scan_info_path) as a dict; None when there is none or it is unreadable."""
|
||||
path = scan_info_path(base)
|
||||
return read_json(path) if path else None
|
||||
|
||||
|
||||
def is_unmatched(info):
|
||||
"""Info the scanner wrote from the file alone: Civitai had no match, or was not reachable then."""
|
||||
return isinstance(info, dict) and info.get("id") == -1
|
||||
|
||||
|
||||
# Why an unmatched model has no Civitai record: Civitai does not know the file; Civitai could
|
||||
# not be reached; the scan was offline. Scans before this was recorded leave it empty.
|
||||
UNMATCHED_REASONS = ("not_found", "network", "offline")
|
||||
|
||||
|
||||
def unmatched_reason(info):
|
||||
"""One of UNMATCHED_REASONS for an unmatched model's info, else ""."""
|
||||
reason = info.get("anomalous_unmatched_reason") if is_unmatched(info) else ""
|
||||
return reason if reason in UNMATCHED_REASONS else ""
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def file_sha256(path):
|
||||
"""The complete file's SHA-256, hex."""
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for block in iter(lambda: handle.read(4096 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def infer_base_model_from_header(file_path: str) -> str:
|
||||
"""从 safetensors 头文件的张量键名推断底层 Base Model (用于脱机/HuggingFace 兼容)"""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
header_size_bytes = f.read(8)
|
||||
if len(header_size_bytes) < 8: return 'Unknown'
|
||||
header_size = struct.unpack('<Q', header_size_bytes)[0]
|
||||
if header_size > 100 * 1024 * 1024: return 'Unknown'
|
||||
|
||||
header_json = json.loads(f.read(header_size).decode('utf-8'))
|
||||
|
||||
# 1. 尝试从 __metadata__ 提取
|
||||
metadata = header_json.get('__metadata__', {})
|
||||
arch = metadata.get('modelspec.architecture', '')
|
||||
if 'stable-diffusion-xl' in arch.lower(): return 'SDXL'
|
||||
if 'stable-diffusion-v1' in arch.lower() or 'runwayml/stable-diffusion-v1-5' in arch.lower(): return 'SD 1.5'
|
||||
if 'flux' in arch.lower(): return 'Flux.1 D'
|
||||
if 'sd3' in arch.lower(): return 'SD3'
|
||||
|
||||
# 2. 暴力张量键名指纹匹配 (Tensor Fingerprinting)
|
||||
# 把前 500 个键拼接成字符串以提高检索效率,大部分核心键都在前面
|
||||
keys_str = " ".join(list(header_json.keys())[:500])
|
||||
|
||||
# Flux 指纹
|
||||
if 'double_blocks.0.img_attn' in keys_str or 'img_in.weight' in keys_str: return 'Flux.1 D'
|
||||
# SD3 指纹
|
||||
if 'joint_blocks.0.x_block' in keys_str: return 'SD3'
|
||||
# SDXL 指纹 (包含两套 text encoder)
|
||||
if 'conditioner.embedders.1.model' in keys_str or 'label_emb.0.0.weight' in keys_str: return 'SDXL'
|
||||
# SD 1.5 指纹
|
||||
if 'cond_stage_model.transformer.text_model' in keys_str or 'model.diffusion_model.input_blocks.0.0.weight' in keys_str: return 'SD 1.5'
|
||||
|
||||
return 'Unknown'
|
||||
except Exception as e:
|
||||
print(f"[-] 离线底模推断失败: {e}")
|
||||
return 'Unknown'
|
||||
@@ -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 Check 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)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Deleting the user's files: always to the system Recycle Bin, never permanently.
|
||||
|
||||
Shared by the API and the standalone scanner. Where no recycle bin exists (a USB stick,
|
||||
a network share, a Linux box without send2trash or gio), nothing is deleted and
|
||||
TrashUnavailable says so; the caller reports it instead of falling back to a permanent
|
||||
delete. Temporary files the plugin made itself are not deleted through here.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
class TrashUnavailable(OSError):
|
||||
"""There is no recycle bin for this location; the files were left in place."""
|
||||
|
||||
|
||||
def _windows_fixed_drive(path):
|
||||
import ctypes
|
||||
|
||||
root = os.path.splitdrive(path)[0]
|
||||
if not root or root.startswith("\\\\"):
|
||||
return False # a UNC share: no recycle bin
|
||||
return ctypes.windll.kernel32.GetDriveTypeW(root + "\\") == 3 # DRIVE_FIXED
|
||||
|
||||
|
||||
def _windows_recycle(paths):
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class SHFILEOPSTRUCTW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("hwnd", wintypes.HWND), ("wFunc", wintypes.UINT),
|
||||
("pFrom", wintypes.LPCWSTR), ("pTo", wintypes.LPCWSTR),
|
||||
("fFlags", ctypes.c_ushort), ("fAnyOperationsAborted", wintypes.BOOL),
|
||||
("hNameMappings", ctypes.c_void_p), ("lpszProgressTitle", wintypes.LPCWSTR),
|
||||
]
|
||||
|
||||
if not all(_windows_fixed_drive(path) for path in paths):
|
||||
raise TrashUnavailable("No Recycle Bin on this drive (removable or network)")
|
||||
# FO_DELETE with FOF_ALLOWUNDO recycles; no dialogs. FOF_WANTNUKEWARNING makes Windows
|
||||
# ask instead of silently deleting if this drive's Recycle Bin is switched off.
|
||||
flags = 0x0040 | 0x0010 | 0x0004 | 0x0400 | 0x4000
|
||||
op = SHFILEOPSTRUCTW(None, 3, "\0".join(paths) + "\0", None, flags, False, None, None)
|
||||
result = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
|
||||
if result != 0 or op.fAnyOperationsAborted:
|
||||
raise OSError(result, f"Could not move to the Recycle Bin (code {result}); the file may be in use")
|
||||
|
||||
|
||||
def _posix_recycle(paths):
|
||||
try:
|
||||
from send2trash import send2trash
|
||||
except ImportError:
|
||||
send2trash = None
|
||||
if send2trash:
|
||||
for path in paths:
|
||||
send2trash(path)
|
||||
return
|
||||
gio = shutil.which("gio")
|
||||
if not gio:
|
||||
raise TrashUnavailable("No trash available (install send2trash)")
|
||||
for path in paths:
|
||||
subprocess.run([gio, "trash", path], check=True, capture_output=True)
|
||||
|
||||
|
||||
def move_to_trash(*paths):
|
||||
"""Moves the existing files and folders among ``paths`` to the Recycle Bin, together.
|
||||
Returns the paths moved. Raises TrashUnavailable (nothing moved) or OSError."""
|
||||
targets = [os.path.abspath(path) for path in paths if path and os.path.lexists(path)]
|
||||
if not targets:
|
||||
return []
|
||||
if sys.platform == "win32":
|
||||
_windows_recycle(targets)
|
||||
else:
|
||||
_posix_recycle(targets)
|
||||
return targets
|
||||
@@ -1,5 +1,8 @@
|
||||
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:
|
||||
@@ -10,130 +13,83 @@ except:
|
||||
pass
|
||||
import time
|
||||
import json
|
||||
import hashlib
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import argparse
|
||||
import shutil
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
# 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,
|
||||
)
|
||||
from model_policies import is_physical_rename_protected
|
||||
from recycle_bin import move_to_trash
|
||||
from model_identity import USER_INFO_SUFFIX, computed_file_identity, file_sha256, infer_base_model_from_header, is_unmatched, scan_info_path, sidecar_file_hash, sidecar_info as read_local_info, unmatched_reason
|
||||
from civitai_client import CIVITAI_API_KEY, CivitaiUnreachable, download_media, fetch_civitai_info, fetch_model_description
|
||||
|
||||
# ==============================================================================
|
||||
# CIVITAI API 配置读取
|
||||
# 请在插件目录 (Anomalous_Model_Browser) 下新建 config.json 文件:
|
||||
# { "CIVITAI_API_KEY": "你的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。部分限制级模型或将无法获取图片。")
|
||||
|
||||
# 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", USER_INFO_SUFFIX, ".json", ".txt", ".yaml",
|
||||
*MEDIA_EXTENSIONS,
|
||||
*PREVIEW_SUFFIXES,
|
||||
*CIVITAI_BACKUP_SUFFIXES,
|
||||
)
|
||||
|
||||
|
||||
def same_file(a, b):
|
||||
"""Byte-for-byte equal (covers are small; size is checked first)."""
|
||||
try:
|
||||
return os.path.getsize(a) == os.path.getsize(b) and calculate_sha256(a) == calculate_sha256(b)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def cover_owners(base):
|
||||
"""(Civitai's, the user's) active covers. A cover is Civitai's only when it is a copy of the
|
||||
Civitai image saved last time (<model>.civitai_bak.*); any other cover is the user's."""
|
||||
backups = [base + ext for ext in CIVITAI_BACKUP_SUFFIXES if os.path.exists(base + ext)]
|
||||
covers = [base + ext for ext in ACTIVE_COVER_SUFFIXES if os.path.exists(base + ext)]
|
||||
civitai = [c for c in covers if any(same_file(c, b) for b in backups)]
|
||||
return civitai, [c for c in covers if c not in civitai]
|
||||
|
||||
|
||||
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, unmatched_only=False):
|
||||
total = 0
|
||||
selected = set(target_files_basenames)
|
||||
for root, _, files in os.walk(target_folder):
|
||||
total += sum(
|
||||
1 for filename in files
|
||||
if filename.endswith(".safetensors") and (not selected or filename in selected)
|
||||
and (not unmatched_only or is_unmatched(read_local_info(os.path.join(root, os.path.splitext(filename)[0]))))
|
||||
)
|
||||
return total
|
||||
|
||||
def calculate_sha256(file_path: str) -> str:
|
||||
"""计算文件的 SHA256 哈希值 (用于 Civitai 匹配)"""
|
||||
sha256_hash = hashlib.sha256()
|
||||
print(f"[*] 正在计算 Hash (大文件可能需要几分钟): {os.path.basename(file_path)}")
|
||||
with open(file_path, "rb") as f:
|
||||
for byte_block in iter(lambda: f.read(4096 * 1024), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
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']
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
return None
|
||||
|
||||
def infer_base_model_from_header(file_path: str) -> str:
|
||||
"""从 safetensors 头文件的张量键名推断底层 Base Model (用于脱机/HuggingFace 兼容)"""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
header_size_bytes = f.read(8)
|
||||
if len(header_size_bytes) < 8: return 'Unknown'
|
||||
header_size = struct.unpack('<Q', header_size_bytes)[0]
|
||||
if header_size > 100 * 1024 * 1024: return 'Unknown'
|
||||
|
||||
header_json = json.loads(f.read(header_size).decode('utf-8'))
|
||||
|
||||
# 1. 尝试从 __metadata__ 提取
|
||||
metadata = header_json.get('__metadata__', {})
|
||||
arch = metadata.get('modelspec.architecture', '')
|
||||
if 'stable-diffusion-xl' in arch.lower(): return 'SDXL'
|
||||
if 'stable-diffusion-v1' in arch.lower() or 'runwayml/stable-diffusion-v1-5' in arch.lower(): return 'SD 1.5'
|
||||
if 'flux' in arch.lower(): return 'Flux.1 D'
|
||||
if 'sd3' in arch.lower(): return 'SD3'
|
||||
|
||||
# 2. 暴力张量键名指纹匹配 (Tensor Fingerprinting)
|
||||
# 把前 500 个键拼接成字符串以提高检索效率,大部分核心键都在前面
|
||||
keys_str = " ".join(list(header_json.keys())[:500])
|
||||
|
||||
# Flux 指纹
|
||||
if 'double_blocks.0.img_attn' in keys_str or 'img_in.weight' in keys_str: return 'Flux.1 D'
|
||||
# SD3 指纹
|
||||
if 'joint_blocks.0.x_block' in keys_str: return 'SD3'
|
||||
# SDXL 指纹 (包含两套 text encoder)
|
||||
if 'conditioner.embedders.1.model' in keys_str or 'label_emb.0.0.weight' in keys_str: return 'SDXL'
|
||||
# SD 1.5 指纹
|
||||
if 'cond_stage_model.transformer.text_model' in keys_str or 'model.diffusion_model.input_blocks.0.0.weight' in keys_str: return 'SD 1.5'
|
||||
|
||||
return 'Unknown'
|
||||
except Exception as e:
|
||||
print(f"[-] 离线底模推断失败: {e}")
|
||||
return 'Unknown'
|
||||
return file_sha256(file_path)
|
||||
|
||||
def sanitize_filename(name: str) -> str:
|
||||
"""清理文件名中的非法字符"""
|
||||
@@ -141,71 +97,212 @@ def sanitize_filename(name: str) -> str:
|
||||
name = re.sub(r'[\\/*?:"<>|#]', "", name)
|
||||
return name.strip(' .')
|
||||
|
||||
def fetch_civitai_info(file_hash: str, max_retries: int = 3) -> Optional[Dict]:
|
||||
"""向 Civitai API 获取模型信息,支持重试机制"""
|
||||
url = f"https://civitai.com/api/v1/model-versions/by-hash/{file_hash}"
|
||||
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}"
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
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
|
||||
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)
|
||||
CIVITAI_DOWN_AFTER = 2 # models in a row Civitai did not answer for before the rest of the run stays offline
|
||||
|
||||
print(f"\033[93m[Skip] 模型 Hash {file_hash} 网络重试失败,已跳过该文件。\033[0m")
|
||||
return None
|
||||
|
||||
def download_media(url: str, base_path: str, max_retries: int = 3):
|
||||
"""下载图片或视频并自动识别扩展名"""
|
||||
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"
|
||||
def write_report(args, **entry):
|
||||
"""One line of the scan report (--report-file): a model's outcome, or an event of the run."""
|
||||
if not args.report_file:
|
||||
return
|
||||
try:
|
||||
with open(args.report_file, 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
except OSError as e:
|
||||
print(f"[-] 扫描记录写入失败: {e}")
|
||||
|
||||
|
||||
def local_record(file_path, root, file_hash, reason):
|
||||
"""What the file alone tells (id -1), with why Civitai's record is missing."""
|
||||
inferred_base = infer_base_model_from_header(file_path)
|
||||
stem = os.path.splitext(os.path.basename(file_path))[0]
|
||||
print(f"[*] 使用本地哈希重建基础元数据 ({os.path.basename(file_path)})")
|
||||
return {
|
||||
"id": -1,
|
||||
"modelId": -1,
|
||||
"name": stem,
|
||||
"baseModel": "" if inferred_base == 'Unknown' else inferred_base,
|
||||
"description": "<p>Automatically inferred by Anomalous Local Engine.</p>",
|
||||
"model": {"name": stem, "type": "LORA" if "lora" in root.lower() else "Checkpoint"},
|
||||
"files": [{"hashes": {"SHA256": file_hash}}],
|
||||
"anomalous_unmatched_reason": reason,
|
||||
}
|
||||
if CIVITAI_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {CIVITAI_API_KEY}"
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
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"
|
||||
elif "video/webm" in content_type: ext = ".webm"
|
||||
elif "image/jpeg" in content_type: ext = ".jpg"
|
||||
elif "image/webp" in content_type: ext = ".webp"
|
||||
elif url.endswith(".mp4"): ext = ".mp4"
|
||||
|
||||
final_path = base_path + ext
|
||||
with open(final_path, 'wb') as f:
|
||||
while True:
|
||||
chunk = response.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
return final_path
|
||||
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 ask_civitai(args, state, file_hash):
|
||||
"""(Civitai's record or None, why it is missing: "not_found" | "network" | "offline" | "")."""
|
||||
if args.offline_only:
|
||||
return None, "offline"
|
||||
if state["civitai_down"]:
|
||||
return None, "network"
|
||||
try:
|
||||
data = fetch_civitai_info(file_hash)
|
||||
except CivitaiUnreachable:
|
||||
state["network_failures"] += 1
|
||||
if state["network_failures"] >= CIVITAI_DOWN_AFTER:
|
||||
state["civitai_down"] = True
|
||||
print("\033[93m[!] 连续几个模型都连不上 Civitai,本次其余模型改为从文件推断,之后可以“重新联网查找”。\033[0m")
|
||||
write_report(args, event="civitai_down")
|
||||
return None, "network"
|
||||
state["network_failures"] = 0
|
||||
return data, ("" if data else "not_found")
|
||||
|
||||
|
||||
def scan_one(args, state, rename_log, root, filename, previous):
|
||||
"""Scans one model; returns its report entry, or None when nothing about it changed."""
|
||||
file_path = os.path.join(root, filename)
|
||||
old_base = os.path.splitext(file_path)[0]
|
||||
online = not args.offline_only and not state["civitai_down"]
|
||||
|
||||
# A .civitai.info holding only edits from older versions is not a scan result.
|
||||
info_exists = scan_info_path(old_base) is not None
|
||||
# Models Civitai was never asked about (an offline scan, no connection then) are looked up
|
||||
# by every online scan; models Civitai did not know wait for "look up again".
|
||||
unasked = is_unmatched(previous) and unmatched_reason(previous) in ("offline", "network")
|
||||
if args.force_overwrite or args.retry_unmatched or (online and unasked):
|
||||
info_exists = False
|
||||
|
||||
preview_exists = args.skip_media or any(os.path.exists(old_base + ext) for ext in COVER_SUFFIXES)
|
||||
needs_rename = bool(args.virtual_rename or (not args.skip_rename and args.physical_rename))
|
||||
if info_exists and preview_exists and not needs_rename:
|
||||
print(f"[*] 已跳过 (信息满足要求): {filename}")
|
||||
return None
|
||||
|
||||
print(f"\n---> 处理文件: {filename} (位于 {root})")
|
||||
civitai_data = None
|
||||
file_hash = None
|
||||
reason = ""
|
||||
if info_exists: # a rename, or a missing cover fetched from the saved record: no new lookup
|
||||
civitai_data = read_local_info(old_base)
|
||||
if civitai_data:
|
||||
print(f"[*] 使用本地已有的信息")
|
||||
|
||||
if not civitai_data:
|
||||
if is_unmatched(previous): # the hash from last time, if the file is unchanged
|
||||
file_hash = sidecar_file_hash(previous, file_path, (previous.get("files") or [{}])[0])[0]
|
||||
file_hash = file_hash or calculate_sha256(file_path)
|
||||
civitai_data, reason = ask_civitai(args, state, file_hash)
|
||||
|
||||
# What was read from disk is not fetched again: only fresh Civitai data needs the model
|
||||
# page's description and the cover.
|
||||
fresh = civitai_data is not None and file_hash is not None
|
||||
if not civitai_data:
|
||||
if args.skip_local_metadata:
|
||||
print(f"[*] Civitai 获取失败,且禁用了本地元数据解析。跳过 {filename}")
|
||||
return {"status": "failed", "error": "no_local_metadata", "reason": reason}
|
||||
civitai_data = local_record(file_path, root, file_hash, reason)
|
||||
entry = {"status": "matched" if fresh else "inferred" if reason else "unchanged"}
|
||||
if reason:
|
||||
entry["reason"] = reason
|
||||
|
||||
model_id = civitai_data.get("modelId")
|
||||
if fresh and model_id and model_id != -1 and not state["civitai_down"]:
|
||||
description = fetch_model_description(model_id)
|
||||
if description:
|
||||
civitai_data["description"] = description
|
||||
if not isinstance(civitai_data.get("model"), dict):
|
||||
civitai_data["model"] = {}
|
||||
civitai_data["model"]["description"] = description
|
||||
|
||||
model_name = sanitize_filename(civitai_data.get("model", {}).get("name", "UnknownModel"))
|
||||
version_name = sanitize_filename(civitai_data.get("name", "UnknownVersion"))
|
||||
if fresh:
|
||||
entry["name"] = f"{civitai_data.get('model', {}).get('name', '')} · {civitai_data.get('name', '')}".strip(" ·")
|
||||
entry["base"] = civitai_data.get("baseModel") or ""
|
||||
new_filename = f"{model_name}_{version_name}.safetensors"
|
||||
new_file_path = os.path.join(root, new_filename)
|
||||
new_base = os.path.splitext(new_file_path)[0]
|
||||
|
||||
info_data = civitai_data
|
||||
if file_hash:
|
||||
info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
|
||||
# What the file alone tells has no standard name: both names are the file's own, so a
|
||||
# rename would only repeat it (name_name, then name_name_name_name on the next scan).
|
||||
inferred = is_unmatched(civitai_data)
|
||||
# Civitai's name for the card; a name set in the model editor lives in
|
||||
# <model>.anomalous.json and is shown instead.
|
||||
if inferred:
|
||||
info_data.pop("anomalous_custom_name", None)
|
||||
elif args.virtual_rename:
|
||||
info_data["anomalous_custom_name"] = f"{model_name}_{version_name}"
|
||||
if not args.dry_run:
|
||||
with open(old_base + ".info", 'w', encoding='utf-8') as f:
|
||||
json.dump(info_data, f, ensure_ascii=True, indent=4)
|
||||
print(f"[+] 写入纯净版 Civitai 描述 -> .info")
|
||||
else:
|
||||
print(f"[Dry-Run] 拟生成标准描述信息 -> .info")
|
||||
|
||||
media_url = None
|
||||
if not args.skip_media:
|
||||
media_url = next((img.get("url") for img in civitai_data.get("images") or [] if img.get("url")), None)
|
||||
civitai_covers, user_covers = cover_owners(old_base)
|
||||
# Download when the data is fresh, or when the model has no cover at all.
|
||||
if media_url and not state["civitai_down"] and (fresh or not (civitai_covers or user_covers)):
|
||||
if not args.dry_run:
|
||||
print(f"[*] 正在下载预览媒体...")
|
||||
saved_path = download_media(media_url, old_base + ".civitai_bak")
|
||||
if saved_path:
|
||||
print(f"[+] 媒体下载成功 -> {os.path.basename(saved_path)}")
|
||||
entry["cover"] = "kept" if user_covers else "new"
|
||||
if user_covers:
|
||||
# The user's cover always stays; Civitai's image is kept as the backup.
|
||||
print(f"[*] 保留你自己的封面,Civitai 封面存为备份")
|
||||
else:
|
||||
ext = os.path.splitext(saved_path)[1]
|
||||
preview_path = old_base + (ext if ext.startswith('.preview.') else f".preview{ext}")
|
||||
# Earlier copies of Civitai's image under another name give way to the new one.
|
||||
older = [c for c in civitai_covers if c != preview_path]
|
||||
try:
|
||||
if older:
|
||||
move_to_trash(*older)
|
||||
except Exception as e:
|
||||
print(f"[-] 旧的 Civitai 封面没有移走: {e}")
|
||||
shutil.copy2(saved_path, preview_path)
|
||||
else:
|
||||
print(f"[Dry-Run] 拟下载预览媒体...")
|
||||
|
||||
if args.skip_rename or not args.physical_rename:
|
||||
print(f"[*] 物理重命名已跳过。仅保存 .info 及其可能包含的虚拟重命名。")
|
||||
elif inferred:
|
||||
print(f"[*] 没有 Civitai 的标准名称,保留原文件名。")
|
||||
elif file_path != new_file_path and new_filename != filename:
|
||||
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")
|
||||
return {**entry, "status": "failed", "error": "name_conflict", "renamed_to": new_file_path}
|
||||
if not args.dry_run:
|
||||
try:
|
||||
print(f"[*] Hash 一致,把已确认的重复副本移到回收站: {filename}")
|
||||
move_to_trash(file_path, *[old_base + ext for ext in SIDECAR_SUFFIXES])
|
||||
entry["duplicate_of"] = new_file_path
|
||||
except Exception as e:
|
||||
print(f"[-] 重复副本没有移走,两份都保留 (可能文件被占用或这个盘没有回收站): {e}")
|
||||
return {**entry, "status": "failed", "error": "duplicate_kept"}
|
||||
else:
|
||||
print(f"[Dry-Run] Hash 一致,拟删除重复副本及其附属文件: {filename}")
|
||||
elif not args.dry_run:
|
||||
os.rename(file_path, new_file_path)
|
||||
rename_log[file_path] = new_file_path
|
||||
for ext in SIDECAR_SUFFIXES:
|
||||
if os.path.exists(old_base + ext):
|
||||
os.replace(old_base + ext, new_base + ext)
|
||||
print(f"[+] 物理重命名完成: {filename} ==> {new_filename}")
|
||||
entry["renamed_to"] = new_file_path
|
||||
else:
|
||||
print(f"[Dry-Run] 拟物理重命名文件: {filename} ==> {new_filename}")
|
||||
print(f"[Dry-Run] 拟连带重命名附属文件 (.info / .png 等)")
|
||||
else:
|
||||
print("[*] 文件名已符合规范,无需重命名。")
|
||||
changed = entry["status"] != "unchanged" or set(entry) & {"cover", "renamed_to", "duplicate_of"}
|
||||
return entry if changed else None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="ComfyUI 模型 Civitai 嗅探与重命名工具")
|
||||
@@ -218,15 +315,28 @@ def main():
|
||||
parser.add_argument("--skip-media", action="store_true", help="不下载预览图或视频")
|
||||
parser.add_argument("--offline-only", action="store_true", help="跳过 Civitai 联网获取,强制使用本地脱机张量推断提取 Base Model")
|
||||
parser.add_argument("--force-overwrite", action="store_true", help="强制覆盖已存在的信息文件")
|
||||
parser.add_argument("--retry-unmatched", action="store_true", help="只处理之前没在 Civitai 匹配到的模型,重新联网查找(文件没变时复用哈希,不替换封面)")
|
||||
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)
|
||||
parser.add_argument("--targets-file", default="", help=argparse.SUPPRESS) # JSON list of file names
|
||||
parser.add_argument("--report-file", default="", help=argparse.SUPPRESS) # one JSON line per changed model
|
||||
parser.add_argument("--civitai-down", action="store_true", help=argparse.SUPPRESS) # an earlier folder could not reach it
|
||||
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 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")
|
||||
|
||||
# ==========================
|
||||
@@ -252,7 +362,7 @@ def main():
|
||||
old_base = os.path.splitext(old_path)[0]
|
||||
new_base = os.path.splitext(new_path)[0]
|
||||
|
||||
for ext in SIDECAR_SUFFIXES:
|
||||
for ext in SIDECAR_SUFFIXES:
|
||||
new_ext_path = new_base + ext
|
||||
old_ext_path = old_base + ext
|
||||
if os.path.exists(new_ext_path):
|
||||
@@ -267,22 +377,21 @@ def main():
|
||||
# 模式二:正常嗅探与重命名
|
||||
# ==========================
|
||||
rename_log = {}
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
if os.path.exists(backup_log_path):
|
||||
with open(backup_log_path, 'r', encoding='utf-8') as f:
|
||||
rename_log = json.load(f)
|
||||
|
||||
target_files_basenames = []
|
||||
target_file_path = os.path.join(target_folder, '.scan_targets.json')
|
||||
if os.path.exists(target_file_path):
|
||||
for targets_path in (os.path.join(target_folder, '.scan_targets.json'), args.targets_file):
|
||||
if not targets_path or not os.path.exists(targets_path):
|
||||
continue
|
||||
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:
|
||||
with open(targets_path, 'r', encoding='utf-8') as f:
|
||||
target_files_basenames += [os.path.basename(t.strip()) for t in json.load(f)]
|
||||
if targets_path != args.targets_file: # the scanner removes its own file
|
||||
os.remove(targets_path)
|
||||
except (OSError, ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
if args.target_files:
|
||||
target_files_basenames.extend([os.path.basename(t.strip()) for t in args.target_files.split(',')])
|
||||
|
||||
@@ -291,250 +400,59 @@ def main():
|
||||
print("==================================================")
|
||||
print("[警告]: 当前处于 Dry-Run (空跑) 模式,不会修改系统中的任何文件!")
|
||||
print("==================================================")
|
||||
if not CIVITAI_API_KEY and not args.offline_only:
|
||||
print("[!] 未配置 Civitai API Key。部分限制级模型或将无法获取图片。")
|
||||
|
||||
write_scan_progress(args.progress_file, "enumerating")
|
||||
total_files = count_scan_files(target_folder, target_files_basenames, args.retry_unmatched)
|
||||
write_scan_progress(args.progress_file, "scanning", total_files)
|
||||
current_file = 0
|
||||
last_progress_write = 0.0
|
||||
state = {"network_failures": 0, "civitai_down": args.civitai_down}
|
||||
counts = {"success": 0, "fail": 0, "unchanged": 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
|
||||
|
||||
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 COVER_SUFFIXES:
|
||||
if os.path.exists(old_base + ext):
|
||||
preview_exists = True
|
||||
break
|
||||
|
||||
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}")
|
||||
previous = read_local_info(os.path.join(root, os.path.splitext(filename)[0]))
|
||||
if args.retry_unmatched and not is_unmatched(previous):
|
||||
continue
|
||||
|
||||
print(f"\n---> 处理文件: {filename} (位于 {root})")
|
||||
|
||||
civitai_data = 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:
|
||||
file_hash = None
|
||||
|
||||
if not args.offline_only:
|
||||
# Fallback 1: Try header hash on Civitai
|
||||
header_hash = extract_safetensors_hash(file_path)
|
||||
if header_hash:
|
||||
print(f"[*] 成功从头文件提取 Hash: {header_hash},尝试请求 Civitai...")
|
||||
civitai_data = fetch_civitai_info(header_hash)
|
||||
if civitai_data:
|
||||
file_hash = header_hash
|
||||
|
||||
# Fallback 2: If header hash fails (or doesn't exist), compute full SHA256
|
||||
if not civitai_data:
|
||||
print(f"[*] 头文件 Hash 未命中或不存在,计算全量物理 SHA256...")
|
||||
full_hash = calculate_sha256(file_path)
|
||||
civitai_data = fetch_civitai_info(full_hash)
|
||||
file_hash = full_hash
|
||||
else:
|
||||
print(f"[*] Offline-only: 跳过 Civitai 获取,将强制使用脱机张量推断")
|
||||
file_hash = extract_safetensors_hash(file_path)
|
||||
if not file_hash:
|
||||
file_hash = calculate_sha256(file_path)
|
||||
|
||||
# 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:
|
||||
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}"
|
||||
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"] = m_data["description"]
|
||||
except Exception as e:
|
||||
print(f"[-] 获取模型主页详细说明失败: {e}")
|
||||
# ----------------------------------
|
||||
|
||||
model_name = sanitize_filename(civitai_data.get("model", {}).get("name", "UnknownModel"))
|
||||
version_name = sanitize_filename(civitai_data.get("name", "UnknownVersion"))
|
||||
|
||||
new_filename = f"{model_name}_{version_name}.safetensors"
|
||||
new_file_path = os.path.join(root, new_filename)
|
||||
new_base = os.path.splitext(new_file_path)[0]
|
||||
|
||||
# ==========================================
|
||||
# 兼容性大刀阔斧改革:直接保存全宇宙最原汁原味的格式
|
||||
# ==========================================
|
||||
info_data = civitai_data
|
||||
|
||||
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:
|
||||
json.dump(info_data, f, ensure_ascii=True, indent=4)
|
||||
print(f"[+] 写入纯净版 Civitai 描述 -> .info")
|
||||
else:
|
||||
print(f"[Dry-Run] 拟生成标准描述信息 -> .info")
|
||||
|
||||
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")
|
||||
break
|
||||
|
||||
if media_url and not args.skip_media:
|
||||
if not args.dry_run:
|
||||
print(f"[*] 正在下载预览媒体...")
|
||||
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 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"[*] 目标文件名已存在,正在验证内容是否完全相同: {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)
|
||||
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 SIDECAR_SUFFIXES:
|
||||
old_ext = old_base + ext
|
||||
new_ext = new_base + ext
|
||||
if os.path.exists(old_ext):
|
||||
os.replace(old_ext, new_ext)
|
||||
|
||||
print(f"[+] 物理重命名完成: {filename} ==> {new_filename}")
|
||||
success_count += 1
|
||||
else:
|
||||
print(f"[Dry-Run] 拟物理重命名文件: {filename} ==> {new_filename}")
|
||||
print(f"[Dry-Run] 拟连带重命名附属文件 (.info / .png 等)")
|
||||
else:
|
||||
print("[*] 文件名已符合规范,无需重命名。")
|
||||
success_count += 1
|
||||
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
entry = scan_one(args, state, rename_log, root, filename, previous)
|
||||
except Exception as e: # one model's trouble does not end the scan of the others
|
||||
print(f"\033[91m[-] 处理失败,已跳过: {filename}: {e}\033[0m")
|
||||
entry = {"status": "failed", "error": str(e)[:300]}
|
||||
if entry is None:
|
||||
counts["unchanged"] += 1
|
||||
continue
|
||||
counts["fail" if entry["status"] == "failed" else "success"] += 1
|
||||
write_report(args, file=os.path.join(root, filename), **entry)
|
||||
|
||||
# Save scan results
|
||||
write_scan_progress(args.progress_file, "complete", total_files, total_files)
|
||||
write_report(args, event="done", unchanged=counts["unchanged"], civitai_down=state["civitai_down"])
|
||||
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:
|
||||
json.dump({"success": success_count, "fail": fail_count}, f)
|
||||
json.dump({"success": counts["success"], "fail": counts["fail"]}, f)
|
||||
except Exception as e:
|
||||
print(f"[-] 保存统计结果失败: {e}")
|
||||
|
||||
|
||||
if not args.dry_run and rename_log:
|
||||
with open(backup_log_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(rename_log, f, ensure_ascii=True, indent=4)
|
||||
print(f"\n[+] 重命名映射日志已保存至: {backup_log_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Structure check, run before every commit: node tools/check_structure.mjs
|
||||
// Fails when a source file is missing from the architecture map, a web module is
|
||||
// no longer imported from an entry, a new file starts over the size threshold, a
|
||||
// file already over it grew since the last commit (AGENTS.md section 2), or a web file
|
||||
// does not parse as the ES module the browser loads.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||
const list = (dir, ext) => fs.readdirSync(path.join(ROOT, dir))
|
||||
.filter((f) => f.endsWith(ext))
|
||||
.map((f) => path.posix.join(dir, f));
|
||||
|
||||
const ENTRIES = ['web/main.js', 'web/hash_resolver.js'];
|
||||
// Language catalogs and data tables are exempt from the size threshold (AGENTS.md section 2).
|
||||
const SIZE_EXEMPT = new Set(['web/modules/locales.js']);
|
||||
const SIZE_LIMIT = 600;
|
||||
|
||||
const pythonFiles = [...list('.', '.py'), ...list('api', '.py')].filter((f) => !f.endsWith('__init__.py'));
|
||||
const webFiles = [...list('web', '.js'), ...list('web/modules', '.js')];
|
||||
const failures = [];
|
||||
|
||||
// 1. Every source file has a line in ARCHITECTURE.md or docs/architecture/.
|
||||
const docs = ['ARCHITECTURE.md', ...list('docs/architecture', '.md')].map(read).join('\n');
|
||||
for (const file of [...pythonFiles, ...webFiles]) {
|
||||
if (!docs.includes(path.posix.basename(file))) failures.push(`${file}: no line in the architecture map`);
|
||||
}
|
||||
|
||||
// 2. Every web module is reachable by imports from an entry.
|
||||
const IMPORT = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)['"](\.{1,2}\/[^'"]+\.js)['"]/g;
|
||||
const reached = new Set();
|
||||
const walk = (file) => {
|
||||
if (reached.has(file) || !fs.existsSync(path.join(ROOT, file))) return;
|
||||
reached.add(file);
|
||||
for (const [, spec] of read(file).matchAll(IMPORT)) walk(path.posix.join(path.posix.dirname(file), spec));
|
||||
};
|
||||
ENTRIES.forEach(walk);
|
||||
for (const file of webFiles) {
|
||||
if (!reached.has(file)) failures.push(`${file}: not imported from ${ENTRIES.join(' or ')}; remove it or wire it in`);
|
||||
}
|
||||
|
||||
// 3. Size threshold: files over it are listed; a new file may not start over it, and a
|
||||
// file already over it may only shrink or stay the same (compared with the last commit).
|
||||
const lineCount = (text) => text.replace(/\r\n/g, '\n').split('\n').length;
|
||||
const committedLines = (file) => {
|
||||
try {
|
||||
return lineCount(execFileSync('git', ['show', `HEAD:${file}`], { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }));
|
||||
} catch (_) {
|
||||
return null; // not committed yet (a new file), or no git here
|
||||
}
|
||||
};
|
||||
const large = [...pythonFiles, ...webFiles]
|
||||
.filter((f) => !SIZE_EXEMPT.has(f))
|
||||
.map((f) => [f, lineCount(read(f))])
|
||||
.filter(([, lines]) => lines > SIZE_LIMIT)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (large.length) {
|
||||
console.log(`Over ${SIZE_LIMIT} lines (may only shrink, AGENTS.md section 2):`);
|
||||
for (const [file, lines] of large) {
|
||||
const before = committedLines(file);
|
||||
const note = before === null ? ' NEW' : lines > before ? ` grew from ${before}` : '';
|
||||
console.log(` ${lines}\t${file}${note}`);
|
||||
if (before === null) failures.push(`${file}: new file over ${SIZE_LIMIT} lines; split it by responsibility`);
|
||||
else if (lines > before) failures.push(`${file}: over ${SIZE_LIMIT} lines and grew from ${before} to ${lines}; move a responsibility out first`);
|
||||
}
|
||||
}
|
||||
// 4. Syntax: every web file must parse as an ES module. `node --check` on a .js file can
|
||||
// pass what the browser rejects (a raw line break inside a string did), so compile them
|
||||
// the way the browser does, in one child process.
|
||||
const PARSE = "const vm = require('node:vm'); const fs = require('node:fs');"
|
||||
+ " for (const f of process.argv.slice(1)) { try { new vm.SourceTextModule(fs.readFileSync(f, 'utf8')); }"
|
||||
+ " catch (e) { console.log(f + ' | ' + e.message); } }";
|
||||
const syntax = execFileSync(process.execPath, ['--experimental-vm-modules', '--no-warnings', '-e', PARSE,
|
||||
...webFiles.map((file) => path.join(ROOT, file))], { encoding: 'utf8' });
|
||||
for (const line of syntax.split(/\r?\n/).filter(Boolean)) {
|
||||
const [file, message] = line.split(' | ');
|
||||
failures.push(`${path.relative(ROOT, file).split(path.sep).join('/')}: does not parse (${message})`);
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\nStructure check failed:\n ${failures.join('\n ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nStructure check passed: ${pythonFiles.length + webFiles.length} source files mapped, ${reached.size} web modules reachable.`);
|
||||
|
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,321 +1,57 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { translate } from './modules/locales.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 { findWorkflowHashRecord } from './modules/recipe_provenance.js';
|
||||
import { requiresHashForModelRecovery } from './modules/model_policies.js';
|
||||
import { detectPlatform, isModelFilename } from './modules/model_source_links.js';
|
||||
import { usableSourceUrl } from './modules/model_source_data.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 || ""
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function inferExpectedModelTypes(node, widget) {
|
||||
const widgetName = String(widget?.name || '').toLowerCase();
|
||||
const nodeType = String(node?.type || '').toLowerCase();
|
||||
const key = `${widgetName} ${nodeType}`;
|
||||
|
||||
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';
|
||||
return '';
|
||||
/**
|
||||
* A model's own download link (its information's, the editor's first) for a workflow that
|
||||
* keeps none of its own for it; `auto` marks it as taken, so a newer one replaces it.
|
||||
*/
|
||||
function addOwnSource(sources, nodeId, value, basename, record) {
|
||||
const url = usableSourceUrl(record?.url);
|
||||
const kept = sources[value] || sources[basename];
|
||||
if (!url || (kept && !kept.auto)) return;
|
||||
sources[value] = { name: value, url, platform: detectPlatform(url)?.name || 'Custom', nodeId, hash: record.hash || '', auto: true };
|
||||
}
|
||||
|
||||
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 (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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) || {};
|
||||
let needsGlobalHashRefresh = false;
|
||||
provenanceCheck:
|
||||
for (const node of app.graph._nodes) {
|
||||
for (const widget of (node.widgets || [])) {
|
||||
const value = widget.value;
|
||||
if (typeof value !== 'string' || !(value.endsWith('.safetensors') || value.endsWith('.ckpt') || value.endsWith('.pt'))) continue;
|
||||
if (!hashes[`${node.id}_${value}`]) {
|
||||
needsGlobalHashRefresh = true;
|
||||
break provenanceCheck;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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 {
|
||||
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] Failed to fetch hashes for manual resolution", e);
|
||||
}
|
||||
}
|
||||
// Pre-fetch all hashes on startup so that dragging generated images (without opening UI) still intercepts
|
||||
await window.anomalous_reload_hashes();
|
||||
|
||||
let fixed_count = 0;
|
||||
|
||||
const findHashData = (node, widget, value) => {
|
||||
let hashData = hashes[`${node.id}_${value}`];
|
||||
if (!hashData && window.anomalous_hash_cache) {
|
||||
const parts = value.split(/[/\\]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
const cacheData = window.anomalous_hash_cache[value] || 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 (typeof value !== 'string' || !(value.endsWith('.safetensors') || value.endsWith('.ckpt') || value.endsWith('.pt'))) 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;
|
||||
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 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.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) {
|
||||
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) {
|
||||
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;
|
||||
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 = true;
|
||||
node.anomalous_original_missing_val = val;
|
||||
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 (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) {
|
||||
alert(translate('hashResolverFixed', { count: fixed_count }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
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.
|
||||
@@ -335,71 +71,107 @@ app.registerExtension({
|
||||
window.anomalous_unscanned_models = [];
|
||||
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)) : {};
|
||||
extraObj.anomalous_hashes = {};
|
||||
let unscanned_models = [];
|
||||
|
||||
if (data.nodes) {
|
||||
const liveNodes = this._nodes || [];
|
||||
for (const node of data.nodes) {
|
||||
const liveNode = liveNodes.find(n => n.id === node.id);
|
||||
|
||||
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'))) {
|
||||
const parts = val.split(/[/\\]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
|
||||
let valIsMissing = false;
|
||||
if (liveNode && liveNode.widgets) {
|
||||
const matchingWidget = liveNode.widgets.find(w => w.value === val && w.type === "combo");
|
||||
if (matchingWidget && matchingWidget.options && matchingWidget.options.values && !matchingWidget.options.values.includes(val)) {
|
||||
valIsMissing = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!valIsMissing) {
|
||||
const cache_data = window.anomalous_hash_cache[val] || 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;
|
||||
}
|
||||
} else {
|
||||
if (!unscanned_models.includes(basename)) {
|
||||
unscanned_models.push(basename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
data.extra = extraObj;
|
||||
|
||||
data.extra = extraObj;
|
||||
window.anomalous_unscanned_models = unscanned_models;
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Intercept loadGraphData to resolve missing models
|
||||
if (typeof app.loadGraphData === 'function') {
|
||||
const origLoadGraphData = app.loadGraphData;
|
||||
app.loadGraphData = function (graphData) {
|
||||
// Proceed with original loadGraphData synchronously first
|
||||
return origLoadGraphData.apply(this, arguments);
|
||||
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;
|
||||
// Download links travel with the fingerprints: the workflow's own, else each model's.
|
||||
const sources = liveSources && typeof liveSources === 'object' ? JSON.parse(JSON.stringify(liveSources)) : {};
|
||||
const usedNames = new Set();
|
||||
let unscanned_models = [];
|
||||
|
||||
if (data.nodes) {
|
||||
const liveNodes = this._nodes || [];
|
||||
for (const node of data.nodes) {
|
||||
const liveNode = liveNodes.find(n => n.id === node.id);
|
||||
|
||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||
for (const val of node.widgets_values) {
|
||||
if (typeof val === 'string') {
|
||||
usedNames.add(val).add(val.replace(/\\/g, '/')).add(val.split(/[/\\]/).pop());
|
||||
}
|
||||
if (isModelFilename(val)) {
|
||||
const parts = val.split(/[/\\]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
|
||||
let valIsMissing = false;
|
||||
let matchingWidget = null;
|
||||
if (liveNode && liveNode.widgets) {
|
||||
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 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) {
|
||||
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;
|
||||
}
|
||||
addOwnSource(sources, node.id, val, basename, { ...hashObj, url: cache_data.url });
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A link for a model no node names any more (replaced, removed) stays behind.
|
||||
for (const name of Object.keys(sources)) if (!usedNames.has(name)) delete sources[name];
|
||||
if (Object.keys(sources).length) extraObj.anomalous_model_sources = sources;
|
||||
else delete extraObj.anomalous_model_sources;
|
||||
data.extra = extraObj;
|
||||
window.anomalous_unscanned_models = unscanned_models;
|
||||
return data;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,585 +1,22 @@
|
||||
import { showDetail, showEditModal, _openAdvancedModelSelector, setWidgetValuePath } from './modules/ui_detail.js';
|
||||
import { loadModels, applyModelToCanvas, stopMediaInContainer } from './modules/ui_grid.js';
|
||||
import { createDOM, renderSidebar, loadFolders, showHelp, hideAllPanels, openFolderManager } from './modules/ui_sidebar.js';
|
||||
import { loadGalleryImages, refreshGalleryImages, showGeneratedGallery, showGallerySelectMode, showGalleryViewer } from './modules/ui_gallery.js';
|
||||
import { showNotebooks, closeWorkspace, refreshNotebooks, saveCurrentNotebook, deleteCurrentNotebook, renderNotebookEditor, fillNotebookGalleries, sendNotebookToCanvas } from './modules/ui_notebooks.js';
|
||||
import { showRecipes, refreshRecipes, renderRecipeList, handleSaveRecipe } from './modules/ui_recipes.js';
|
||||
import { initDoctorPanel, diagnoseNode, renderGlobalDashboard, initAssistantPanel, renderAssistantModelCard, _loadAssistantHistory, _openGalleryReplacer, openLoraInsertionPicker, runGlobalDoctorScan } from './modules/ui_doctor.js';
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { normalizeLocale, resolveLocale, translate } from './modules/locales.js';
|
||||
// ============================================================================
|
||||
// TABLE OF CONTENTS (TOC)
|
||||
// 1. App Registration & Entry (Search for "app.registerExtension")
|
||||
// 2. State & Class Constructor (Search for "class AnomalousBrowser")
|
||||
// 3. UI - Sidebar (Search for "createDOM")
|
||||
// 4. UI - Main Grid (Search for "renderGrid")
|
||||
// 5. UI - Detail Panel (Search for "showDetail")
|
||||
// 6. UI - Gallery Viewer (Search for "createGalleryViewer")
|
||||
// 7. UI - Doctor Panel (Search for "createDoctorPanel")
|
||||
// 8. Notebooks & Workflow Recipes (Search for "Notebook" or "Recipes")
|
||||
// ============================================================================
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { createBrowserEntry } from "./modules/browser_entry.js";
|
||||
import { createInterfaceSettings, getCurrentLanguage, setAbyssalScarletTheme, t } from "./modules/interface_settings.js";
|
||||
import { isModelFilename } from "./modules/model_source_links.js";
|
||||
|
||||
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'))) {
|
||||
const val = localStorage.getItem(key);
|
||||
if (typeof val === 'string') {
|
||||
const lowerVal = val.toLowerCase();
|
||||
if (lowerVal.includes('zh') || lowerVal.includes('chinese')) {
|
||||
defaultLang = 'zh';
|
||||
comfyDetected = true;
|
||||
break;
|
||||
} else if (lowerVal.includes('en') || lowerVal.includes('english')) {
|
||||
defaultLang = 'en';
|
||||
comfyDetected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export { setAbyssalScarletTheme };
|
||||
|
||||
if (!comfyDetected && navigator.language && !navigator.language.toLowerCase().startsWith('zh')) {
|
||||
defaultLang = 'en';
|
||||
}
|
||||
} catch (e) {
|
||||
if (navigator.language && !navigator.language.toLowerCase().startsWith('zh')) {
|
||||
defaultLang = 'en';
|
||||
}
|
||||
}
|
||||
let currentLang = resolveLocale(localStorage.getItem('anomalous_lang') || defaultLang);
|
||||
window.anomalous_browser_lang = currentLang;
|
||||
const t = (key, params) => translate(key, params, window.anomalous_browser_lang || currentLang);
|
||||
|
||||
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.createDOM();
|
||||
}
|
||||
// [EXTRACTED] createDOM
|
||||
// [EXTRACTED] showHelp
|
||||
// [EXTRACTED] loadFolders
|
||||
// [EXTRACTED] renderSidebar
|
||||
// [EXTRACTED] loadModels
|
||||
// [EXTRACTED] applyModelToCanvas
|
||||
// [EXTRACTED] stopMediaInContainer
|
||||
// [EXTRACTED] showDetail
|
||||
|
||||
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() {
|
||||
this.modal.classList.remove('visible');
|
||||
this.setTriggerVisible(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);
|
||||
}
|
||||
// [EXTRACTED] showNotebooks
|
||||
|
||||
async translateText(text) {
|
||||
if (!text || !text.trim()) return "";
|
||||
try {
|
||||
const res = await fetch('/anomalous/translate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: text, target_lang: window.anomalous_browser_lang === 'zh' ? 'zh-CN' : 'en' })
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.translated || text;
|
||||
} catch (e) { return text; }
|
||||
}
|
||||
// [EXTRACTED] loadGalleryImages
|
||||
// [EXTRACTED] showEditModal
|
||||
// [EXTRACTED] showGeneratedGallery
|
||||
// [EXTRACTED] showGallerySelectMode
|
||||
// [EXTRACTED] showGalleryViewer
|
||||
// [EXTRACTED] refreshNotebooks
|
||||
// [EXTRACTED] saveCurrentNotebook
|
||||
// [EXTRACTED] deleteCurrentNotebook
|
||||
// [EXTRACTED] renderNotebookEditor
|
||||
// [EXTRACTED] fillNotebookGalleries
|
||||
// [EXTRACTED] sendNotebookToCanvas
|
||||
// [EXTRACTED] setWidgetValuePath
|
||||
// [EXTRACTED] hideAllPanels
|
||||
|
||||
// [Doctor & Assistant Panel methods extracted to ui_doctor.js]
|
||||
|
||||
showPreflightImportModal() {
|
||||
let overlay = document.getElementById('anomalous-import-overlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'anomalous-import-overlay';
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100vw';
|
||||
overlay.style.height = '100vh';
|
||||
overlay.style.backgroundColor = 'rgba(0,0,0,0.85)';
|
||||
overlay.style.zIndex = '9999999';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.alignItems = 'center';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.style.width = '600px';
|
||||
modal.style.maxWidth = '90vw';
|
||||
modal.style.background = '#222';
|
||||
modal.style.borderRadius = '12px';
|
||||
modal.style.padding = '20px';
|
||||
modal.style.border = '1px solid rgba(255,255,255,0.1)';
|
||||
modal.style.boxShadow = '0 10px 30px rgba(0,0,0,0.5)';
|
||||
modal.style.display = 'flex';
|
||||
modal.style.flexDirection = 'column';
|
||||
modal.style.gap = '15px';
|
||||
|
||||
const header = document.createElement('h2');
|
||||
header.style.margin = '0';
|
||||
header.style.color = '#fff';
|
||||
header.textContent = t('mainPreflightTitle');
|
||||
modal.appendChild(header);
|
||||
|
||||
const desc = document.createElement('div');
|
||||
desc.style.color = '#aaa';
|
||||
desc.style.fontSize = '14px';
|
||||
desc.textContent = t('mainPreflightDesc');
|
||||
modal.appendChild(desc);
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.id = 'anomalous-import-textarea';
|
||||
textarea.style.width = '100%';
|
||||
textarea.style.height = '150px';
|
||||
textarea.style.background = '#111';
|
||||
textarea.style.color = '#fff';
|
||||
textarea.style.border = '1px solid #444';
|
||||
textarea.style.borderRadius = '6px';
|
||||
textarea.style.padding = '10px';
|
||||
textarea.style.fontFamily = 'monospace';
|
||||
textarea.style.resize = 'vertical';
|
||||
textarea.style.boxSizing = 'border-box';
|
||||
textarea.placeholder = '{"last_node_id": ...}';
|
||||
modal.appendChild(textarea);
|
||||
|
||||
const resultArea = document.createElement('div');
|
||||
resultArea.id = 'anomalous-import-result';
|
||||
resultArea.style.maxHeight = '300px';
|
||||
resultArea.style.overflowY = 'auto';
|
||||
resultArea.style.display = 'none';
|
||||
resultArea.style.flexDirection = 'column';
|
||||
resultArea.style.gap = '10px';
|
||||
resultArea.style.background = 'rgba(0,0,0,0.2)';
|
||||
resultArea.style.padding = '15px';
|
||||
resultArea.style.borderRadius = '8px';
|
||||
modal.appendChild(resultArea);
|
||||
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.style.display = 'flex';
|
||||
btnRow.style.justifyContent = 'flex-end';
|
||||
btnRow.style.gap = '10px';
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.textContent = t('mainCancel');
|
||||
closeBtn.style.padding = '8px 16px';
|
||||
closeBtn.style.background = 'transparent';
|
||||
closeBtn.style.color = '#ccc';
|
||||
closeBtn.style.border = '1px solid #555';
|
||||
closeBtn.style.borderRadius = '6px';
|
||||
closeBtn.style.cursor = 'pointer';
|
||||
closeBtn.onclick = () => { overlay.style.display = 'none'; };
|
||||
|
||||
const analyzeBtn = document.createElement('button');
|
||||
analyzeBtn.id = 'anomalous-import-analyze-btn';
|
||||
analyzeBtn.textContent = t('mainAnalyzePreflight');
|
||||
analyzeBtn.style.padding = '8px 16px';
|
||||
analyzeBtn.style.background = '#1a73e8';
|
||||
analyzeBtn.style.color = '#fff';
|
||||
analyzeBtn.style.border = 'none';
|
||||
analyzeBtn.style.borderRadius = '6px';
|
||||
analyzeBtn.style.cursor = 'pointer';
|
||||
|
||||
const loadBtn = document.createElement('button');
|
||||
loadBtn.id = 'anomalous-import-load-btn';
|
||||
loadBtn.textContent = t('mainForceLoad');
|
||||
loadBtn.style.padding = '8px 16px';
|
||||
loadBtn.style.background = '#28a745';
|
||||
loadBtn.style.color = '#fff';
|
||||
loadBtn.style.border = 'none';
|
||||
loadBtn.style.borderRadius = '6px';
|
||||
loadBtn.style.cursor = 'pointer';
|
||||
loadBtn.style.display = 'none';
|
||||
|
||||
let parsedGraphData = null;
|
||||
|
||||
analyzeBtn.onclick = async () => {
|
||||
try {
|
||||
const jsonStr = textarea.value.trim();
|
||||
if (!jsonStr) return;
|
||||
parsedGraphData = JSON.parse(jsonStr);
|
||||
|
||||
try {
|
||||
const res = await fetch('/anomalous/all_hashes');
|
||||
const hData = await res.json();
|
||||
const hObj = hData.hashes ? hData.hashes : hData;
|
||||
Object.assign(window.anomalous_hash_cache, hObj);
|
||||
} catch (e) { }
|
||||
|
||||
const models = [];
|
||||
if (parsedGraphData.nodes) {
|
||||
for (const node of parsedGraphData.nodes) {
|
||||
if (node.widgets_values) {
|
||||
for (const v of node.widgets_values) {
|
||||
if (typeof v === 'string' && (v.endsWith('.safetensors') || v.endsWith('.ckpt') || v.endsWith('.pt') || v.endsWith('.sft') || v.endsWith('.bin'))) {
|
||||
models.push({ nodeType: node.type, value: v });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea.style.display = 'none';
|
||||
resultArea.style.display = 'flex';
|
||||
analyzeBtn.style.display = 'none';
|
||||
loadBtn.style.display = 'block';
|
||||
|
||||
if (models.length === 0) {
|
||||
const noDependencies = document.createElement('div');
|
||||
noDependencies.style.color = '#28a745';
|
||||
noDependencies.textContent = t('mainNoDependencies');
|
||||
resultArea.replaceChildren(noDependencies);
|
||||
return;
|
||||
}
|
||||
|
||||
const detectedHeader = document.createElement('div');
|
||||
detectedHeader.style.color = '#fff';
|
||||
detectedHeader.style.fontWeight = 'bold';
|
||||
detectedHeader.style.marginBottom = '10px';
|
||||
detectedHeader.textContent = t('mainDetectedModels', { count: models.length });
|
||||
resultArea.replaceChildren(detectedHeader);
|
||||
|
||||
for (const m of models) {
|
||||
const val = m.value;
|
||||
const parts = val.split(/[\\/]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
|
||||
let isHealthy = false;
|
||||
let cacheHit = window.anomalous_hash_cache[val] || window.anomalous_hash_cache[basename];
|
||||
|
||||
const fetchRes = await fetch(`/anomalous/resolve_hash?hash=unknown&size=&filename=${encodeURIComponent(val)}`);
|
||||
const fetchData = await fetchRes.json();
|
||||
if (fetchData.found) {
|
||||
isHealthy = true;
|
||||
} else if (cacheHit) {
|
||||
const resolveRes = await fetch(`/anomalous/resolve_hash?hash=${encodeURIComponent(cacheHit.hash || cacheHit)}&size=`);
|
||||
const resolveData = await resolveRes.json();
|
||||
if (resolveData.found) isHealthy = true;
|
||||
}
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.style.display = 'flex';
|
||||
item.style.alignItems = 'center';
|
||||
item.style.justifyContent = 'space-between';
|
||||
item.style.padding = '8px';
|
||||
item.style.background = 'rgba(255,255,255,0.05)';
|
||||
item.style.borderRadius = '4px';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.style.display = 'flex';
|
||||
left.style.alignItems = 'center';
|
||||
left.style.gap = '8px';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.innerText = isHealthy ? '✅' : '⚠️';
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.innerText = `[${m.nodeType}] ${basename}`;
|
||||
text.style.color = isHealthy ? '#ccc' : '#ff6b6b';
|
||||
text.style.fontSize = '14px';
|
||||
|
||||
left.appendChild(icon);
|
||||
left.appendChild(text);
|
||||
item.appendChild(left);
|
||||
|
||||
if (!isHealthy) {
|
||||
const right = document.createElement('a');
|
||||
const searchHash = cacheHit ? (typeof cacheHit === 'string' ? cacheHit : cacheHit.hash) : null;
|
||||
const searchStr = searchHash || basename.replace('.safetensors', '').replace('.ckpt', '').replace('.pt', '').replace('.sft', '');
|
||||
right.href = `https://civitai.com/search/models?sortBy=models_v9&query=${encodeURIComponent(searchStr)}`;
|
||||
right.target = '_blank';
|
||||
right.textContent = t('mainDownloadCivitai');
|
||||
right.style.color = '#1a73e8';
|
||||
right.style.fontSize = '12px';
|
||||
right.style.textDecoration = 'none';
|
||||
item.appendChild(right);
|
||||
} else {
|
||||
const right = document.createElement('span');
|
||||
right.textContent = t('mainReady');
|
||||
right.style.color = '#28a745';
|
||||
right.style.fontSize = '12px';
|
||||
item.appendChild(right);
|
||||
}
|
||||
|
||||
resultArea.appendChild(item);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
alert(t('mainInvalidJson') + ' ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
loadBtn.onclick = () => {
|
||||
if (parsedGraphData && app) {
|
||||
app.loadGraphData(parsedGraphData);
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
btnRow.appendChild(closeBtn);
|
||||
btnRow.appendChild(analyzeBtn);
|
||||
btnRow.appendChild(loadBtn);
|
||||
modal.appendChild(btnRow);
|
||||
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
const textarea = document.getElementById('anomalous-import-textarea');
|
||||
const resultArea = document.getElementById('anomalous-import-result');
|
||||
const analyzeBtn = document.getElementById('anomalous-import-analyze-btn');
|
||||
const loadBtn = document.getElementById('anomalous-import-load-btn');
|
||||
|
||||
if (textarea) {
|
||||
textarea.value = '';
|
||||
textarea.style.display = 'block';
|
||||
}
|
||||
if (resultArea) {
|
||||
resultArea.style.display = 'none';
|
||||
resultArea.innerHTML = '';
|
||||
}
|
||||
if (analyzeBtn) analyzeBtn.style.display = 'block';
|
||||
if (loadBtn) loadBtn.style.display = 'none';
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
}
|
||||
// [EXTRACTED] _openAdvancedModelSelector
|
||||
|
||||
}
|
||||
|
||||
|
||||
// --- Extracted Module Bindings ---
|
||||
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.loadGalleryImages = loadGalleryImages;
|
||||
AnomalousBrowser.prototype.refreshGalleryImages = refreshGalleryImages;
|
||||
AnomalousBrowser.prototype.showGeneratedGallery = showGeneratedGallery;
|
||||
AnomalousBrowser.prototype.showGallerySelectMode = showGallerySelectMode;
|
||||
AnomalousBrowser.prototype.showGalleryViewer = showGalleryViewer;
|
||||
|
||||
AnomalousBrowser.prototype.createDOM = createDOM;
|
||||
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;
|
||||
const browserEntry = createBrowserEntry({ translate: t, getCurrentLanguage });
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Anomalous.ModelBrowser',
|
||||
async setup() {
|
||||
const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=' + Date.now();
|
||||
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);
|
||||
}
|
||||
if (!localStorage.getItem('anomalous_lang')) {
|
||||
try {
|
||||
if (app && app.ui && app.ui.settings) {
|
||||
const locale = app.ui.settings.getSettingValue('Comfy.Locale') || app.ui.settings.getSettingValue('Comfy.Locale.Language');
|
||||
if (locale) {
|
||||
currentLang = normalizeLocale(locale) || defaultLang;
|
||||
window.anomalous_browser_lang = currentLang;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
let browser = null;
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'anomalous-trigger-btn';
|
||||
btn.textContent = '📦';
|
||||
btn.setAttribute('aria-label', 'Anomalous Model Browser');
|
||||
|
||||
const ensureBrowser = () => {
|
||||
if (browser) return browser;
|
||||
try {
|
||||
browser = new AnomalousBrowser();
|
||||
browser.triggerButton = btn;
|
||||
window.anomalousBrowserInstance = browser;
|
||||
btn.classList.remove('anomalous-trigger-error');
|
||||
return browser;
|
||||
} catch (error) {
|
||||
console.error('[Anomalous Model Browser] UI initialization failed:', error);
|
||||
btn.classList.add('anomalous-trigger-error');
|
||||
btn.title = currentLang === 'zh' ? t('mainRetryInit') : t('mainRetryInitEn');
|
||||
btn.setAttribute('aria-label', btn.title);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
btn.title = t('mainOpenTitle');
|
||||
let isDragging = false;
|
||||
let startX, startY, initialX, initialY;
|
||||
|
||||
btn.addEventListener('mousedown', (e) => {
|
||||
isDragging = true;
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
initialX = rect.left; initialY = rect.top;
|
||||
btn.style.transition = 'none';
|
||||
});
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
e.preventDefault();
|
||||
let newX = initialX + (e.clientX - startX);
|
||||
let newY = initialY + (e.clientY - startY);
|
||||
if (newX < 0) newX = 0;
|
||||
if (newY < 0) newY = 0;
|
||||
if (newX > window.innerWidth - 60) newX = window.innerWidth - 60;
|
||||
if (newY > window.innerHeight - 60) newY = window.innerHeight - 60;
|
||||
btn.style.left = newX + 'px';
|
||||
btn.style.top = newY + 'px';
|
||||
btn.style.right = 'auto'; btn.style.bottom = 'auto';
|
||||
});
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
btn.style.transition = 'transform 0.15s, box-shadow 0.15s';
|
||||
localStorage.setItem('anomalous_btn_x', btn.style.left);
|
||||
localStorage.setItem('anomalous_btn_y', btn.style.top);
|
||||
if (Math.abs(e.clientX - startX) < 5 && Math.abs(e.clientY - startY) < 5) {
|
||||
ensureBrowser()?.show();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const savedX = localStorage.getItem('anomalous_btn_x');
|
||||
const savedY = localStorage.getItem('anomalous_btn_y');
|
||||
|
||||
const updateBtnBounds = () => {
|
||||
let numX = parseInt(btn.style.left || savedX);
|
||||
let numY = parseInt(btn.style.top || savedY);
|
||||
if (isNaN(numX)) numX = window.innerWidth - 90;
|
||||
if (isNaN(numY)) numY = window.innerHeight - 90;
|
||||
|
||||
if (numX < 0) numX = 0;
|
||||
if (numY < 0) numY = 0;
|
||||
if (numX > window.innerWidth - 60) numX = window.innerWidth - 60;
|
||||
if (numY > window.innerHeight - 60) numY = window.innerHeight - 60;
|
||||
btn.style.left = numX + 'px';
|
||||
btn.style.top = numY + 'px';
|
||||
};
|
||||
|
||||
if (savedX && savedY && savedX !== 'NaN' && savedY !== 'NaN') {
|
||||
btn.style.right = 'auto';
|
||||
btn.style.bottom = 'auto';
|
||||
btn.style.left = savedX;
|
||||
btn.style.top = savedY;
|
||||
}
|
||||
|
||||
// Always trigger an update slightly after load to ensure it's in bounds
|
||||
setTimeout(updateBtnBounds, 200);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (btn.style.left) updateBtnBounds();
|
||||
});
|
||||
|
||||
document.body.appendChild(btn);
|
||||
// Keep the entry point visible even when a panel regression prevents
|
||||
// the full browser UI from being constructed.
|
||||
ensureBrowser();
|
||||
|
||||
// Pre-create a lightweight, translucent, and aesthetic drag ghost image for huge Hires Fix images
|
||||
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='%231a1a1a' fill-opacity='0.6' rx='16' stroke='%2300ffcc' stroke-width='2'/><text x='40' y='50' font-family='sans-serif' font-size='32' font-weight='bold' fill='%2300ffcc' text-anchor='middle'>W</text></svg>";
|
||||
}
|
||||
name: "Anomalous.ModelBrowser",
|
||||
settings: [...browserEntry.settings, ...createInterfaceSettings()],
|
||||
actionBarButtons: browserEntry.actionBarButtons,
|
||||
commands: browserEntry.commands,
|
||||
keybindings: browserEntry.keybindings,
|
||||
menuCommands: browserEntry.menuCommands,
|
||||
setup: browserEntry.setup
|
||||
});
|
||||
|
||||
|
||||
// --- INJECTED WORKFLOW SHARE MODULE ---
|
||||
// Workflow Share and Preview Modal for Anomalous_Model_Browser
|
||||
|
||||
@@ -799,7 +236,7 @@ const AMB_WorkflowShare = {
|
||||
const toast = document.createElement('div');
|
||||
toast.textContent = message;
|
||||
toast.style.cssText = `
|
||||
position: fixed; bottom: 30px; right: 30px; background: #2a2a2b; color: ${color || '#fff'};
|
||||
position: fixed; bottom: 30px; right: 30px; background: var(--amb-bg-card-hover); color: ${color || '#fff'};
|
||||
padding: 12px 20px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||||
font-family: Arial, sans-serif; font-size: 14px; z-index: 9999999;
|
||||
opacity: 0; transition: opacity 0.3s ease; border-left: 4px solid ${color || '#fff'};
|
||||
@@ -812,214 +249,172 @@ const AMB_WorkflowShare = {
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
showExportModal() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'amb-export-modal';
|
||||
overlay.style.cssText = `
|
||||
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
|
||||
background: rgba(0,0,0,0.6); backdrop-filter: blur(5px);
|
||||
z-index: 999999; display: flex; justify-content: center; align-items: center;
|
||||
font-family: Arial, sans-serif;
|
||||
`;
|
||||
|
||||
/** A dimmed overlay with one dialog box; looks come from .anomalous-share-* in the styles. */
|
||||
createShareDialog(id, titleText, variant = '') {
|
||||
const overlay = document.createElement('div');
|
||||
if (id) overlay.id = id;
|
||||
overlay.className = 'anomalous-share-overlay';
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = `
|
||||
background: #2a2a2b; color: #fff; padding: 30px; border-radius: 12px;
|
||||
width: 500px; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
display: flex; flex-direction: column; gap: 20px;
|
||||
`;
|
||||
|
||||
content.className = `anomalous-share-dialog${variant ? ` ${variant}` : ''}`;
|
||||
const title = document.createElement('h2');
|
||||
title.style.margin = '0';
|
||||
title.textContent = t('mainExportTitle');
|
||||
|
||||
title.className = 'anomalous-share-title';
|
||||
title.textContent = titleText;
|
||||
content.appendChild(title);
|
||||
overlay.appendChild(content);
|
||||
return { overlay, content };
|
||||
},
|
||||
|
||||
shareButton(label, variant = '') {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `anomalous-share-btn${variant ? ` ${variant}` : ''}`;
|
||||
button.textContent = label;
|
||||
return button;
|
||||
},
|
||||
|
||||
/** What a workflow tells others about its models: how many carry a fingerprint, how many links. */
|
||||
provenanceSummary(workflow) {
|
||||
const models = new Set();
|
||||
for (const node of workflow.nodes || []) {
|
||||
for (const value of Array.isArray(node.widgets_values) ? node.widgets_values : []) {
|
||||
if (isModelFilename(value)) models.add(`${node.id}_${value}`);
|
||||
}
|
||||
}
|
||||
const hashes = workflow.extra?.anomalous_hashes || {};
|
||||
const hashed = [...models].filter(key => hashes[key]?.hash).length;
|
||||
return { models: models.size, hashed, links: Object.keys(workflow.extra?.anomalous_model_sources || {}).length };
|
||||
},
|
||||
|
||||
provenanceNote(summary) {
|
||||
if (!summary.models) return '';
|
||||
if (localStorage.getItem('anomalous_inject_hash') === 'false') return t('mainShareProvenanceOff');
|
||||
const missing = summary.models - summary.hashed;
|
||||
return [t('mainShareFingerprints', { hashed: summary.hashed, total: summary.models }),
|
||||
missing ? t('mainShareNoFingerprint', { count: missing }) : ''].filter(Boolean).join(' ');
|
||||
},
|
||||
|
||||
showExportModal() {
|
||||
const { overlay, content } = this.createShareDialog('amb-export-modal', t('mainExportTitle'));
|
||||
const summary = this.provenanceSummary(app.graph.serialize());
|
||||
const note = document.createElement('p');
|
||||
note.className = 'anomalous-share-note';
|
||||
note.textContent = this.provenanceNote(summary);
|
||||
note.hidden = !note.textContent;
|
||||
const linksLabel = document.createElement('label');
|
||||
linksLabel.className = 'anomalous-share-check';
|
||||
const linksBox = document.createElement('input');
|
||||
linksBox.type = 'checkbox';
|
||||
linksBox.checked = true;
|
||||
linksLabel.append(linksBox, ` ${t('mainShareLinks', { count: summary.links })}`);
|
||||
linksLabel.hidden = !summary.links;
|
||||
|
||||
const typeSelectContainer = document.createElement('div');
|
||||
typeSelectContainer.className = 'anomalous-share-options';
|
||||
typeSelectContainer.innerHTML = `
|
||||
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
|
||||
<label>
|
||||
<input type="radio" name="amb-share-type" value="skeleton" checked />
|
||||
${t('mainSkeletonOption')}
|
||||
${t('mainSkeletonOption')}
|
||||
</label>
|
||||
<label style="display: block; cursor: pointer;">
|
||||
<label>
|
||||
<input type="radio" name="amb-share-type" value="full" />
|
||||
${t('mainFullOption')}
|
||||
${t('mainFullOption')}
|
||||
</label>
|
||||
`;
|
||||
|
||||
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.style.cssText = `
|
||||
width: 100%; height: 150px; background: #1e1e1f; color: #eee;
|
||||
border: 1px solid #444; border-radius: 6px; padding: 10px;
|
||||
font-family: monospace; font-size: 12px; resize: none; box-sizing: border-box;
|
||||
`;
|
||||
textArea.className = 'anomalous-share-code';
|
||||
textArea.readOnly = true;
|
||||
|
||||
|
||||
const btnGroup = document.createElement('div');
|
||||
btnGroup.style.cssText = `display: flex; gap: 10px; justify-content: flex-end;`;
|
||||
|
||||
const generateBtn = document.createElement('button');
|
||||
generateBtn.textContent = t('mainGenerate');
|
||||
generateBtn.style.cssText = `padding: 8px 16px; background: #4a90e2; color: #fff; border: none; border-radius: 6px; cursor: pointer;`;
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.textContent = t('mainCopyClipboard');
|
||||
copyBtn.style.cssText = `padding: 8px 16px; background: #5cb85c; color: #fff; border: none; border-radius: 6px; cursor: pointer; display: none;`;
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.textContent = t('mainClose');
|
||||
closeBtn.style.cssText = `padding: 8px 16px; background: #555; color: #fff; border: none; border-radius: 6px; cursor: pointer;`;
|
||||
|
||||
btnGroup.className = 'anomalous-share-actions';
|
||||
const generateBtn = this.shareButton(t('mainGenerate'), 'is-primary');
|
||||
const copyBtn = this.shareButton(t('mainCopyClipboard'));
|
||||
copyBtn.hidden = true;
|
||||
const closeBtn = this.shareButton(t('mainClose'), 'is-quiet');
|
||||
|
||||
closeBtn.onclick = () => overlay.remove();
|
||||
|
||||
|
||||
generateBtn.onclick = async () => {
|
||||
const isSkeleton = document.querySelector('input[name="amb-share-type"]:checked').value === 'skeleton';
|
||||
|
||||
|
||||
// Get current workflow from app graph
|
||||
const p = await app.graphToPrompt();
|
||||
const workflowJson = p.workflow;
|
||||
|
||||
if (!linksBox.checked) delete workflowJson.extra?.anomalous_model_sources;
|
||||
|
||||
try {
|
||||
const code = await AMB_WorkflowShare.encodeShareCode(workflowJson, isSkeleton);
|
||||
textArea.value = code;
|
||||
copyBtn.style.display = 'block';
|
||||
copyBtn.hidden = false;
|
||||
} catch (err) {
|
||||
textArea.value = 'Error generating code: ' + err.message;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
copyBtn.onclick = () => {
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
AMB_WorkflowShare.showToast(t('mainCopied'), '#5cb85c');
|
||||
AMB_WorkflowShare.showToast(t('mainCopied'), '#5cb85c');
|
||||
};
|
||||
|
||||
btnGroup.appendChild(generateBtn);
|
||||
btnGroup.appendChild(copyBtn);
|
||||
btnGroup.appendChild(closeBtn);
|
||||
|
||||
content.appendChild(title);
|
||||
content.appendChild(typeSelectContainer);
|
||||
content.appendChild(textArea);
|
||||
content.appendChild(btnGroup);
|
||||
overlay.appendChild(content);
|
||||
|
||||
|
||||
btnGroup.append(generateBtn, copyBtn, closeBtn);
|
||||
content.append(typeSelectContainer, note, linksLabel, textArea, btnGroup);
|
||||
document.body.appendChild(overlay);
|
||||
},
|
||||
|
||||
|
||||
showImportModal() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'amb-import-modal';
|
||||
overlay.style.cssText = `
|
||||
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
|
||||
background: rgba(0,0,0,0.6); backdrop-filter: blur(5px);
|
||||
z-index: 999999; display: flex; justify-content: center; align-items: center;
|
||||
font-family: Arial, sans-serif;
|
||||
`;
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = `
|
||||
background: #2a2a2b; color: #fff; padding: 30px; border-radius: 12px;
|
||||
width: 600px; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
display: flex; flex-direction: column; gap: 20px;
|
||||
`;
|
||||
|
||||
const title = document.createElement('h2');
|
||||
title.style.margin = '0';
|
||||
title.textContent = t('mainImportTitle');
|
||||
|
||||
const { overlay, content } = this.createShareDialog('amb-import-modal', t('mainImportTitle'), 'is-wide');
|
||||
|
||||
const inputArea = document.createElement('textarea');
|
||||
inputArea.placeholder = t('mainSharePlaceholder');
|
||||
inputArea.style.cssText = `
|
||||
width: 100%; height: 100px; background: #1e1e1f; color: #eee;
|
||||
border: 1px solid #444; border-radius: 6px; padding: 10px;
|
||||
font-family: monospace; font-size: 12px; resize: none; box-sizing: border-box;
|
||||
`;
|
||||
|
||||
inputArea.className = 'anomalous-share-code is-input';
|
||||
inputArea.placeholder = t('mainSharePlaceholder');
|
||||
|
||||
const btnGroup = document.createElement('div');
|
||||
btnGroup.style.cssText = `display: flex; gap: 10px; justify-content: flex-end;`;
|
||||
|
||||
const loadBtn = document.createElement('button');
|
||||
loadBtn.textContent = t('mainImportLoad');
|
||||
loadBtn.style.cssText = `padding: 8px 16px; background: #e07a5f; color: #fff; border: none; border-radius: 6px; cursor: pointer;`;
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.textContent = t('mainCancel');
|
||||
closeBtn.style.cssText = `padding: 8px 16px; background: #555; color: #fff; border: none; border-radius: 6px; cursor: pointer;`;
|
||||
|
||||
btnGroup.className = 'anomalous-share-actions';
|
||||
const loadBtn = this.shareButton(t('mainImportLoad'), 'is-primary');
|
||||
const closeBtn = this.shareButton(t('mainCancel'), 'is-quiet');
|
||||
|
||||
closeBtn.onclick = () => overlay.remove();
|
||||
|
||||
|
||||
loadBtn.onclick = async () => {
|
||||
const code = inputArea.value.trim();
|
||||
if (!code) {
|
||||
AMB_WorkflowShare.showToast(t('mainShareEmpty'), '#ff6b6b');
|
||||
AMB_WorkflowShare.showToast(t('mainShareEmpty'), '#ff6b6b');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pendingWorkflow = await AMB_WorkflowShare.decodeShareCode(code);
|
||||
app.loadGraphData(pendingWorkflow);
|
||||
overlay.remove();
|
||||
|
||||
|
||||
const nodesCount = pendingWorkflow.nodes ? pendingWorkflow.nodes.length : 0;
|
||||
AMB_WorkflowShare.showToast(t('mainImportedNodes', { count: nodesCount }), '#5cb85c');
|
||||
|
||||
AMB_WorkflowShare.showToast(t('mainImportedNodes', { count: nodesCount }), '#5cb85c');
|
||||
|
||||
// Auto close the main browser panel
|
||||
const mainCloseBtn = document.getElementById('anomalous-close');
|
||||
if (mainCloseBtn) mainCloseBtn.click();
|
||||
} catch (err) {
|
||||
AMB_WorkflowShare.showToast(t('mainDecodeFailed') + err.message, '#ff6b6b');
|
||||
AMB_WorkflowShare.showToast(t('mainDecodeFailed') + err.message, '#ff6b6b');
|
||||
}
|
||||
};
|
||||
|
||||
btnGroup.appendChild(loadBtn);
|
||||
btnGroup.appendChild(closeBtn);
|
||||
|
||||
content.appendChild(title);
|
||||
content.appendChild(inputArea);
|
||||
content.appendChild(btnGroup);
|
||||
overlay.appendChild(content);
|
||||
|
||||
|
||||
btnGroup.append(loadBtn, closeBtn);
|
||||
content.append(inputArea, btnGroup);
|
||||
document.body.appendChild(overlay);
|
||||
},
|
||||
showUnifiedModal() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = `
|
||||
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
|
||||
background: rgba(0,0,0,0.6); backdrop-filter: blur(5px);
|
||||
z-index: 999999; display: flex; justify-content: center; align-items: center;
|
||||
font-family: Arial, sans-serif;
|
||||
`;
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = `
|
||||
background: #2a2a2b; color: #fff; padding: 30px; border-radius: 12px;
|
||||
width: 400px; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
display: flex; flex-direction: column; gap: 20px; text-align: center;
|
||||
`;
|
||||
|
||||
const title = document.createElement('h2');
|
||||
title.style.margin = '0';
|
||||
title.textContent = t('mainUnifiedTitle');
|
||||
|
||||
const exportBtn = document.createElement('button');
|
||||
exportBtn.textContent = t('mainExportWorkflow');
|
||||
exportBtn.style.cssText = `padding: 12px; background: #4a90e2; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px;`;
|
||||
const { overlay, content } = this.createShareDialog('', t('mainUnifiedTitle'), 'is-menu');
|
||||
|
||||
const exportBtn = this.shareButton(t('mainExportWorkflow'), 'is-primary is-large');
|
||||
exportBtn.onclick = () => { overlay.remove(); this.showExportModal(); };
|
||||
|
||||
const importBtn = document.createElement('button');
|
||||
importBtn.textContent = t('mainImportWorkflow');
|
||||
importBtn.style.cssText = `padding: 12px; background: #e07a5f; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px;`;
|
||||
|
||||
const importBtn = this.shareButton(t('mainImportWorkflow'), 'is-large');
|
||||
importBtn.onclick = () => { overlay.remove(); this.showImportModal(); };
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.textContent = t('mainClose');
|
||||
closeBtn.style.cssText = `padding: 8px; background: #555; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 12px; margin-top: 10px;`;
|
||||
|
||||
const closeBtn = this.shareButton(t('mainClose'), 'is-quiet');
|
||||
closeBtn.onclick = () => overlay.remove();
|
||||
|
||||
content.appendChild(title);
|
||||
content.appendChild(exportBtn);
|
||||
content.appendChild(importBtn);
|
||||
content.appendChild(closeBtn);
|
||||
overlay.appendChild(content);
|
||||
|
||||
|
||||
content.append(exportBtn, importBtn, closeBtn);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Records the canvas changes Anomalous makes. A pointer press anywhere in Anomalous's
|
||||
* interface takes a snapshot of the canvas; the next press outside it (or any key
|
||||
* outside it, or the next press inside) compares the canvas with that snapshot and
|
||||
* logs the difference, before the user's own edit happens. So every button, drag and
|
||||
* dialog that writes to nodes is covered without each one reporting itself.
|
||||
* Opening another workflow meanwhile is logged as that, not as removed nodes. What each
|
||||
* entry did is handed to canvas_undo.js, so the log can undo it.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { diffSnapshots, snapshotGraph } from './activity_diff.js';
|
||||
import { postCanvasActivity } from './activity_log.js';
|
||||
import { keepUndo, workflowKey as openWorkflowKey } from './canvas_undo.js';
|
||||
|
||||
const AMB_UI = '#anomalous-modal, [id^="anomalous-"], [class*="anomalous-"]';
|
||||
const MAX_SENT = 60;
|
||||
|
||||
const currentGraph = () => app.canvas?.graph || app.graph || null;
|
||||
const activeWorkflow = () => app.extensionManager?.workflow?.activeWorkflow || null;
|
||||
const workflowName = () => {
|
||||
const workflow = activeWorkflow();
|
||||
return String(workflow?.filename || workflow?.path || '');
|
||||
};
|
||||
const workflowKey = () => openWorkflowKey(app);
|
||||
|
||||
// <html> carries Anomalous classes too (the floating entry setting); it is not our interface.
|
||||
const insideAnomalous = (target) => {
|
||||
const hit = target instanceof Element ? target.closest(AMB_UI) : null;
|
||||
return Boolean(hit) && hit !== document.documentElement && hit !== document.body;
|
||||
};
|
||||
|
||||
/** Starts watching; sets `owner.flushCanvasActivity()` (log what is pending now). */
|
||||
export function watchCanvasChanges(owner) {
|
||||
let baseline = null;
|
||||
|
||||
const flush = () => {
|
||||
// A combo group following the pointer is logged once it is put down; Esc removes it.
|
||||
if (!baseline || owner.placingCombo) return;
|
||||
const { graph, workflow, nodes } = baseline;
|
||||
baseline = null;
|
||||
const now = currentGraph();
|
||||
if (!now || now !== graph) return; // moved into or out of a subgraph meanwhile
|
||||
const after = snapshotGraph(now);
|
||||
const opened = workflowKey() !== workflow;
|
||||
const changes = opened ? [{ kind: 'opened', node: workflowName(), after: String(after.size) }] : diffSnapshots(nodes, after);
|
||||
if (!changes.length) return;
|
||||
postCanvasActivity({ changes: changes.slice(0, MAX_SENT), total: changes.length, workflow: workflowName() })
|
||||
.then((data) => {
|
||||
if (!opened) keepUndo(data?.entry?.id, workflow, nodes, after);
|
||||
owner.onActivityRecorded?.();
|
||||
})
|
||||
.catch((error) => console.warn('[AMB] Activity log: canvas change not recorded.', error));
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
flush();
|
||||
const graph = currentGraph();
|
||||
baseline = graph ? { graph, workflow: workflowKey(), nodes: snapshotGraph(graph) } : null;
|
||||
};
|
||||
|
||||
// Capture phase: runs before ComfyUI or Anomalous handle the event.
|
||||
window.addEventListener('pointerdown', (e) => {
|
||||
if (insideAnomalous(e.target)) start();
|
||||
else flush();
|
||||
}, true);
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (!insideAnomalous(e.target)) flush();
|
||||
}, true);
|
||||
|
||||
owner.flushCanvasActivity = flush;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Canvas snapshots and their difference, for the activity log. Pure: takes a graph-like
|
||||
* object (`_nodes` or `nodes`, each with `id`, `type`, `title`, `widgets`), no DOM.
|
||||
*/
|
||||
|
||||
/** Text form of a widget value, so values compare and store the same way. */
|
||||
export function valueText(value) {
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value) ?? '';
|
||||
} catch (_) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `Map(node id → { type, title, widgets: { name → text }, links })`; buttons have no value.
|
||||
* `links`: the node's input links, so a rewiring shows (canvas_undo.js uses it).
|
||||
*/
|
||||
export function snapshotGraph(graph) {
|
||||
const nodes = new Map();
|
||||
for (const node of graph?._nodes || graph?.nodes || []) {
|
||||
if (node?.id === undefined || node.id === null) continue;
|
||||
const widgets = {};
|
||||
for (const widget of node.widgets || []) {
|
||||
if (!widget?.name || widget.type === 'button') continue;
|
||||
widgets[widget.name] = valueText(widget.value);
|
||||
}
|
||||
const links = (node.inputs || []).map(input => input?.link ?? '').join(',');
|
||||
nodes.set(String(node.id), { type: node.type || '', title: node.title || node.type || '', widgets, links });
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const nodeLabel = (id, node) => `${node.title} #${id}`;
|
||||
|
||||
/**
|
||||
* What changed from `before` to `after` (both from snapshotGraph): widget values
|
||||
* (`changed`), nodes `added` and `removed`, in canvas order.
|
||||
*/
|
||||
export function diffSnapshots(before, after) {
|
||||
const changes = [];
|
||||
for (const [id, now] of after) {
|
||||
const was = before.get(id);
|
||||
if (!was) {
|
||||
changes.push({ kind: 'added', node: nodeLabel(id, now), type: now.type });
|
||||
continue;
|
||||
}
|
||||
for (const name of new Set([...Object.keys(was.widgets), ...Object.keys(now.widgets)])) {
|
||||
if (was.widgets[name] === now.widgets[name]) continue;
|
||||
changes.push({
|
||||
kind: 'changed', node: nodeLabel(id, now), type: now.type, widget: name,
|
||||
before: was.widgets[name] ?? '', after: now.widgets[name] ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [id, was] of before) {
|
||||
if (!after.has(id)) changes.push({ kind: 'removed', node: nodeLabel(id, was), type: was.type });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** The node id in a change's `node` label (`KSampler #12` → `12`), or null. */
|
||||
export function changeNodeId(change) {
|
||||
const match = /#([^#\s]+)$/.exec(String(change?.node || ''));
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Activity log client: reading, adding canvas entries, clearing; and the words for
|
||||
* each entry. The server (`api/activity_log.py`) keeps the list and records file
|
||||
* changes itself.
|
||||
*/
|
||||
|
||||
import { translate as t } from './locales.js';
|
||||
import { countsLine, fileOutcome, scanScope, STATUS_MARKS } from './scan_results.js';
|
||||
|
||||
// Server action id → locale key.
|
||||
const ACTION_KEYS = {
|
||||
model_delete: 'activityModelDelete',
|
||||
model_edit: 'activityModelEdit',
|
||||
model_cover: 'activityModelCover',
|
||||
scan: 'activityScan', // entries from before scans were recorded with their result
|
||||
recipe_save: 'activityRecipeSave',
|
||||
recipe_edit: 'activityRecipeEdit',
|
||||
recipe_delete: 'activityRecipeDelete',
|
||||
recipe_restore: 'activityRecipeRestore',
|
||||
recipe_cover: 'activityRecipeCover',
|
||||
recipe_import: 'activityRecipeImport',
|
||||
material_save: 'activityMaterialSave',
|
||||
material_edit: 'activityMaterialEdit',
|
||||
material_delete: 'activityMaterialDelete',
|
||||
note_save: 'activityNoteSave',
|
||||
note_delete: 'activityNoteDelete',
|
||||
preset_save: 'activityPresetSave',
|
||||
preset_rename: 'activityPresetRename',
|
||||
preset_delete: 'activityPresetDelete',
|
||||
image_delete: 'activityImageDelete',
|
||||
audio_delete: 'activityAudioDelete',
|
||||
audio_save: 'activityAudioSave',
|
||||
cache_clear: 'activityCacheClear',
|
||||
voice_import: 'activityVoiceImport',
|
||||
voice_settings: 'activityVoiceSettings',
|
||||
};
|
||||
|
||||
async function request(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Newest first. `source`: 'canvas' | 'file' | undefined (both). */
|
||||
export async function fetchActivity({ limit = 100, source = '', signal } = {}) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (source) params.set('source', source);
|
||||
const data = await request(`/anomalous/activity?${params}`, { signal });
|
||||
return Array.isArray(data.entries) ? data.entries : [];
|
||||
}
|
||||
|
||||
export function postCanvasActivity(payload) {
|
||||
return request('/anomalous/activity', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearActivity() {
|
||||
return request('/anomalous/activity/clear', { method: 'POST' });
|
||||
}
|
||||
|
||||
/** One line saying what an entry did. */
|
||||
export function entrySummary(entry) {
|
||||
if (entry.source === 'canvas') {
|
||||
const changes = entry.detail?.changes || [];
|
||||
const opened = changes.find(change => change.kind === 'opened');
|
||||
if (opened) return t('activityCanvasOpened', { name: opened.node || '—', count: opened.after || '?' });
|
||||
return t('activityCanvasChanged', { count: entry.detail?.total || changes.length });
|
||||
}
|
||||
if (entry.action === 'scan_done') {
|
||||
const scan = entry.detail?.scan || {};
|
||||
return t('activityScanDone', { scope: scanScope(scan, entry.target), counts: countsLine(scan.counts) });
|
||||
}
|
||||
const words = t(ACTION_KEYS[entry.action] || 'activityUnknown');
|
||||
return entry.target ? t('activityWithTarget', { action: words, target: entry.target }) : words;
|
||||
}
|
||||
|
||||
const FIELD_KEYS = {
|
||||
custom_name: 'modelSourceFieldName', custom_notes: 'modelSourceFieldNotes',
|
||||
source_url: 'modelSourceFieldLink', filename: 'activityFieldFilename',
|
||||
};
|
||||
|
||||
/**
|
||||
* The lines a file entry opens to: [{text, model?}], `model` being a scanned model the line
|
||||
* can open ({type, path_idx, rel, filename}). Empty for entries without details.
|
||||
*/
|
||||
export function fileDetailLines(entry) {
|
||||
const detail = entry.detail || {};
|
||||
const empty = t('activityEmptyValue');
|
||||
const lines = [];
|
||||
if (detail.scan?.civitai_down) lines.push({ text: t('scanResultCivitaiDown') });
|
||||
for (const error of detail.scan?.errors || []) lines.push({ text: error });
|
||||
for (const item of detail.files || []) {
|
||||
if (typeof item === 'string') lines.push({ text: t('activityFileMoved', { name: item }) });
|
||||
else lines.push({ text: `${STATUS_MARKS[item.status] || '·'} ${item.filename}: ${fileOutcome(item)}`, model: item.rel ? item : null });
|
||||
}
|
||||
for (const field of detail.fields || []) {
|
||||
lines.push({ text: t('activityFieldChange', {
|
||||
field: t(FIELD_KEYS[field.field] || field.field), before: field.before || empty, after: field.after || empty,
|
||||
}) });
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** One line per canvas change (not for `opened`). */
|
||||
export function changeLine(change) {
|
||||
const empty = t('activityEmptyValue');
|
||||
if (change.kind === 'added') return t('activityAdded', { node: change.node });
|
||||
if (change.kind === 'removed') return t('activityRemoved', { node: change.node });
|
||||
return t('activityChangedValue', {
|
||||
node: change.node, widget: change.widget, before: change.before || empty, after: change.after || empty,
|
||||
});
|
||||
}
|
||||
|
||||
/** Day heading for grouping: today, yesterday, or the date. */
|
||||
export function dayLabel(seconds, now = new Date()) {
|
||||
const day = new Date(seconds * 1000);
|
||||
const start = date => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
const diff = Math.round((start(now) - start(day)) / 86400000);
|
||||
if (diff === 0) return t('activityToday');
|
||||
if (diff === 1) return t('activityYesterday');
|
||||
return day.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function timeLabel(seconds) {
|
||||
return new Date(seconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { TTS_NODE_CLASS } from './audio_script.js';
|
||||
|
||||
/**
|
||||
* The speech engine the audio page manages: GPT-SoVITS through the Anomalous_TTS
|
||||
* node pack. Anomalous never bundles it; it is detected at runtime, and a missing
|
||||
* pack only changes what the audio page shows.
|
||||
*
|
||||
* Characters are normalised into "voice groups" that the studio cards, sidebar
|
||||
* and Script Director share:
|
||||
* { group, character, language, has_main, node_value, total_slices, raw,
|
||||
* slices: [{ emotion, is_main, text, audio_url, syntax_tag, relative_path }] }
|
||||
* `node_value` is what goes into the node's `character` widget.
|
||||
*/
|
||||
export const TTS_ENGINE = Object.freeze({
|
||||
label: 'GPT-SoVITS',
|
||||
pack: 'Anomalous_TTS',
|
||||
repoUrl: 'https://github.com/DemonGatanjieu/Anomalous_TTS',
|
||||
managerSearch: 'Anomalous TTS',
|
||||
});
|
||||
|
||||
/** The voice group (and audio-page filter value) of a GPT-SoVITS character. */
|
||||
export function voiceGroupKey(name) {
|
||||
return `gpt_sovits:${name}`;
|
||||
}
|
||||
|
||||
// ---------- normalisation ----------
|
||||
|
||||
export function ttsAudioUrl(name, path) {
|
||||
return `/anomalous_tts/audio?character=${encodeURIComponent(name)}&path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
function ttsSlice(name, emotion, ref, isMain) {
|
||||
return {
|
||||
id: `${name}:${emotion}`,
|
||||
character: name,
|
||||
emotion,
|
||||
is_main: isMain,
|
||||
filename: String(ref.audio || '').split('/').pop(),
|
||||
relative_path: ref.audio || '',
|
||||
text: ref.text || '',
|
||||
synthesis_text: ref.text || '',
|
||||
language: ref.language || '',
|
||||
source: ref.source || '',
|
||||
syntax_tag: `{${emotion}}`,
|
||||
audio_url: ref.audio ? ttsAudioUrl(name, ref.audio) : '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Anomalous_TTS `GET /anomalous_tts/characters` → voice groups (docs: the node repo's docs/INTERFACE.md). */
|
||||
export function gptSovitsGroups(payload) {
|
||||
return (payload?.characters || []).filter(item => item && typeof item.name === 'string').map(item => {
|
||||
const name = item.name;
|
||||
const slices = [];
|
||||
if (item.reference?.audio) slices.push(ttsSlice(name, 'main', item.reference, true));
|
||||
for (const [emotion, ref] of Object.entries(item.emotions || {})) {
|
||||
if (emotion !== 'main' && ref?.audio) slices.push(ttsSlice(name, emotion, ref, false));
|
||||
}
|
||||
return {
|
||||
group: voiceGroupKey(name),
|
||||
character: name,
|
||||
language: item.language || '',
|
||||
aliases: Array.isArray(item.aliases) ? item.aliases : [],
|
||||
error: item.error || item.settings_error || '',
|
||||
has_main: Boolean(item.reference?.audio) && !item.error,
|
||||
node_value: name,
|
||||
total_slices: slices.length,
|
||||
slices,
|
||||
raw: item,
|
||||
};
|
||||
}).sort((a, b) => (b.has_main - a.has_main) || a.character.localeCompare(b.character));
|
||||
}
|
||||
|
||||
// ---------- detection + loading ----------
|
||||
//
|
||||
// Switching to the audio domain, clicking a sidebar entry or saving a setting all
|
||||
// re-render the studio and the sidebar. They must not hit the server each time:
|
||||
// `/object_info/<class>` makes ComfyUI rebuild that node's inputs (a folder scan),
|
||||
// and a character list can be large. So both results are kept until something
|
||||
// says they are stale: the Refresh button, a save, an added voice, or MAX_AGE_MS.
|
||||
|
||||
const MAX_AGE_MS = 60_000;
|
||||
let installCache = null; // { at, promise } — node pack presence
|
||||
let loadCache = null; // { at, promise } — the character list
|
||||
let rescanNext = false;
|
||||
|
||||
async function getJson(url, signal) {
|
||||
const resp = await fetch(url, { signal });
|
||||
const data = await resp.json().catch(() => null);
|
||||
return { ok: resp.ok, status: resp.status, data };
|
||||
}
|
||||
|
||||
/** Installed = ComfyUI knows the node class (`/object_info/<class>` is `{}` otherwise). */
|
||||
async function isNodeInstalled(nodeClass) {
|
||||
try {
|
||||
const { ok, data } = await getJson(`/object_info/${encodeURIComponent(nodeClass)}`);
|
||||
return ok && Boolean(data && data[nodeClass]);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fresh(entry) {
|
||||
return entry && Date.now() - entry.at < MAX_AGE_MS;
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
// The node caches its folder scan; `refresh=1` makes it look at the disk again.
|
||||
const url = rescanNext ? '/anomalous_tts/characters?refresh=1' : '/anomalous_tts/characters';
|
||||
rescanNext = false;
|
||||
const { ok, status, data } = await getJson(url);
|
||||
if (!ok || !data) throw new Error(`HTTP ${status}`);
|
||||
return gptSovitsGroups(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget cached engine data. `rescan: true` (the Refresh button) also asks the
|
||||
* node to re-read its folders instead of answering from its own cache.
|
||||
*/
|
||||
export function invalidateEngineCache({ rescan = false } = {}) {
|
||||
loadCache = null;
|
||||
installCache = null;
|
||||
statusCache = null;
|
||||
if (rescan) rescanNext = true;
|
||||
}
|
||||
|
||||
let statusCache = null; // { at, promise } — GPT-SoVITS setup status
|
||||
|
||||
/**
|
||||
* The GPT-SoVITS node's setup status (libraries, pretrained files, packages; interface v3),
|
||||
* or null for a node that predates it (no `/anomalous_tts/status`). Cached like loadVoices;
|
||||
* `force` skips the cache (download progress polling).
|
||||
*/
|
||||
export function loadGptSovitsStatus({ force = false } = {}) {
|
||||
if (!force && fresh(statusCache)) return statusCache.promise;
|
||||
const promise = getJson('/anomalous_tts/status').then(({ ok, status, data }) => {
|
||||
if (status === 404) return null;
|
||||
if (!ok || !data) throw new Error(`HTTP ${status}`);
|
||||
return data;
|
||||
});
|
||||
statusCache = { at: Date.now(), promise };
|
||||
promise.catch(() => { if (statusCache?.promise === promise) statusCache = null; });
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* { installed, groups, error }: the GPT-SoVITS characters, none while the node pack
|
||||
* is missing. Concurrent callers (studio + sidebar) share one request.
|
||||
*/
|
||||
export function loadVoices() {
|
||||
if (fresh(loadCache)) return loadCache.promise;
|
||||
const promise = loadVoicesNow();
|
||||
loadCache = { at: Date.now(), promise };
|
||||
// A failure must not stick for a minute.
|
||||
promise.then(result => { if (result.error && loadCache?.promise === promise) loadCache = null; });
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function loadVoicesNow() {
|
||||
const installed = await isTtsInstalled();
|
||||
if (!installed) return { installed, groups: [], error: '' };
|
||||
try {
|
||||
return { installed, groups: await loadGroups(), error: '' };
|
||||
} catch (e) {
|
||||
return { installed, groups: [], error: e.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether ComfyUI has the Anomalous_TTS node. Cached like loadVoices. */
|
||||
export function isTtsInstalled() {
|
||||
if (fresh(installCache)) return installCache.promise;
|
||||
const promise = isNodeInstalled(TTS_NODE_CLASS);
|
||||
installCache = { at: Date.now(), promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* One GPT-SoVITS character with its file lists (`gpt`, `sovits`, `audio`), which the
|
||||
* summary list leaves out because a folder can hold thousands of clips.
|
||||
*/
|
||||
export async function fetchGptSovitsCharacter(name, signal) {
|
||||
const { ok, status, data } = await getJson(`/anomalous_tts/characters?name=${encodeURIComponent(name)}`, signal);
|
||||
if (!ok || !data?.character) throw new Error(status === 404 ? `404 ${name}` : `HTTP ${status}`);
|
||||
return data.character;
|
||||
}
|
||||
|
||||
// ---------- GPT-SoVITS settings (Anomalous writes, the node reads) ----------
|
||||
|
||||
/**
|
||||
* Replace a character's anomalous_tts.json through the node's API. `settings` must be the
|
||||
* full object (unknown fields preserved by the caller). Returns the refreshed group.
|
||||
*/
|
||||
export async function saveGptSovitsSettings(name, settings) {
|
||||
const resp = await fetch('/anomalous_tts/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ character: name, settings }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const message = await resp.text().catch(() => '');
|
||||
throw new Error(message || `HTTP ${resp.status}`);
|
||||
}
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
invalidateEngineCache();
|
||||
return data.character ? gptSovitsGroups({ characters: [data.character] })[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the new settings for an emotion editor result without dropping fields
|
||||
* Anomalous does not know. `main` = { audio, text } | null; `emotions` = [{ name, audio, text }].
|
||||
*/
|
||||
export function mergeGptSovitsSettings(previous, { main, emotions }) {
|
||||
const next = { ...(previous || {}), format: 1 };
|
||||
if (main?.audio) {
|
||||
const kept = previous?.reference && typeof previous.reference === 'object' ? previous.reference : {};
|
||||
next.reference = { ...kept, audio: main.audio };
|
||||
if (main.text) next.reference.text = main.text; else delete next.reference.text;
|
||||
} else {
|
||||
delete next.reference;
|
||||
}
|
||||
const oldEmotions = previous?.emotions && typeof previous.emotions === 'object' ? previous.emotions : {};
|
||||
const out = {};
|
||||
for (const row of emotions || []) {
|
||||
const name = String(row?.name || '').trim();
|
||||
if (!name || name === 'main' || !row.audio) continue;
|
||||
const kept = oldEmotions[name] && typeof oldEmotions[name] === 'object' ? oldEmotions[name] : {};
|
||||
out[name] = { ...kept, audio: row.audio };
|
||||
if (row.text) out[name].text = row.text; else delete out[name].text;
|
||||
}
|
||||
if (Object.keys(out).length) next.emotions = out; else delete next.emotions;
|
||||
return next;
|
||||
}
|
||||