Merge pull request #49 from space-nuko/subgraphs4

Subgraphs
This commit is contained in:
space-nuko
2023-05-18 02:51:28 -05:00
committed by GitHub
78 changed files with 27976 additions and 24044 deletions

View File

@@ -19,7 +19,7 @@
}
</script>
<body>
<div id="app"/>
<div id="app-root"/>
<script type="module" src='/src/main-desktop.ts'></script>
</body>
</html>

2
klecks

Submodule klecks updated: a36de3203f...f08ba31888

View File

@@ -8,7 +8,7 @@
<meta name="theme-color" content="#2196f3">
</head>
<body>
<div id="app" class="mobile">
<div id="app-root" class="mobile">
<script type="module" src='/src/main-mobile.ts'></script>
</body>
</html>

View File

@@ -10,6 +10,7 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:inspect": "vitest --inspect-brk --single-thread",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"svelte-check": "svelte-check",
@@ -17,10 +18,14 @@
"build:css": "pollen -c gradio/js/theme/src/pollen.config.cjs && mv src/pollen.css node_modules/@gradio/theme/src"
},
"devDependencies": {
"@floating-ui/core": "^1.2.6",
"@floating-ui/dom": "^1.2.8",
"@zerodevx/svelte-toast": "^0.9.3",
"eslint": "^8.37.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-svelte3": "^4.0.0",
"happy-dom": "^9.18.3",
"jsdom": "^22.0.0",
"prettier": "^2.8.7",
"prettier-plugin-svelte": "^2.10.0",
"sass": "^1.61.0",
@@ -33,7 +38,7 @@
"vite-plugin-static-copy": "^0.14.0",
"vite-plugin-svelte-console-remover": "^1.0.10",
"vite-tsconfig-paths": "^4.0.8",
"vitest": "^0.25.8"
"vitest": "^0.27.3"
},
"type": "module",
"dependencies": {

331
pnpm-lock.yaml generated
View File

@@ -122,6 +122,12 @@ importers:
specifier: ^1.0.5
version: 1.0.5(vite@4.3.1)
devDependencies:
'@floating-ui/core':
specifier: ^1.2.6
version: 1.2.6
'@floating-ui/dom':
specifier: ^1.2.8
version: 1.2.8
'@zerodevx/svelte-toast':
specifier: ^0.9.3
version: 0.9.3(svelte@3.58.0)
@@ -134,6 +140,12 @@ importers:
eslint-plugin-svelte3:
specifier: ^4.0.0
version: 4.0.0(eslint@8.37.0)(svelte@3.58.0)
happy-dom:
specifier: ^9.18.3
version: 9.18.3
jsdom:
specifier: ^22.0.0
version: 22.0.0
prettier:
specifier: ^2.8.7
version: 2.8.7
@@ -171,8 +183,8 @@ importers:
specifier: ^4.0.8
version: 4.0.8(typescript@5.0.3)(vite@4.3.1)
vitest:
specifier: ^0.25.8
version: 0.25.8(sass@1.61.0)
specifier: ^0.27.3
version: 0.27.3(happy-dom@9.18.3)(jsdom@22.0.0)(sass@1.61.0)
gradio/client/js:
dependencies:
@@ -1677,13 +1689,11 @@ packages:
/@floating-ui/core@1.2.6:
resolution: {integrity: sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==}
dev: false
/@floating-ui/dom@1.2.6:
resolution: {integrity: sha512-02vxFDuvuVPs22iJICacezYJyf7zwwOCWkPNkWNBr1U0Qt1cKFYzWvxts0AmqcOQGwt/3KJWcWIgtbUU38keyw==}
/@floating-ui/dom@1.2.8:
resolution: {integrity: sha512-XLwhYV90MxiHDq6S0rzFZj00fnDM+A1R9jhSioZoMsa7G0Q0i+Q4x40ajR8FHSdYDE1bgjG45mIWe6jtv9UPmg==}
dependencies:
'@floating-ui/core': 1.2.6
dev: false
/@humanwhocodes/config-array@0.11.8:
resolution: {integrity: sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==}
@@ -3053,6 +3063,11 @@ packages:
tslib: 2.5.0
dev: false
/@tootallnate/once@2.0.0:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
dev: true
/@trysound/sax@0.2.0:
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'}
@@ -3361,6 +3376,10 @@ packages:
svelte: 3.58.0
dev: true
/abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
dev: true
/abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
dev: false
@@ -3395,6 +3414,15 @@ packages:
pako: 2.1.0
dev: false
/agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
dependencies:
debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: true
/ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
dependencies:
@@ -3476,7 +3504,6 @@ packages:
/asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: false
/automation-events@6.0.0:
resolution: {integrity: sha512-mSOnckbtuJso8cczB+broNsfcuDIQ+J4GFhlW/V3iD+LAXbS4XGHhdjYvtPnCKclKcvs9cVLtUMUkNncOzUQPg==}
@@ -3698,7 +3725,6 @@ packages:
/cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
dev: false
/call-bind@1.0.2:
resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
@@ -3896,7 +3922,6 @@ packages:
engines: {node: '>= 0.8'}
dependencies:
delayed-stream: 1.0.0
dev: false
/commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -4053,6 +4078,10 @@ packages:
engines: {node: '>= 6'}
dev: false
/css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
dev: true
/cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -4065,6 +4094,13 @@ packages:
css-tree: 1.1.3
dev: false
/cssstyle@3.0.0:
resolution: {integrity: sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==}
engines: {node: '>=14'}
dependencies:
rrweb-cssom: 0.6.0
dev: true
/d3-array@3.2.2:
resolution: {integrity: sha512-yEEyEAbDrF8C6Ob2myOBLjwBLck1Z89jMGFee0oPsn95GqjerpaOA4ch+vc2l0FNFFwMD5N7OCSEN5eAlsUbgQ==}
engines: {node: '>=12'}
@@ -4227,6 +4263,15 @@ packages:
engines: {node: '>=4'}
dev: false
/data-urls@4.0.0:
resolution: {integrity: sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==}
engines: {node: '>=14'}
dependencies:
abab: 2.0.6
whatwg-mimetype: 3.0.0
whatwg-url: 12.0.1
dev: true
/datalib@1.9.3:
resolution: {integrity: sha512-9rcwGd3zhvmJChyLzL5jjZ6UEtWO0SKa9Ycy6RVoQxSW43TSOBRbizj/Zn8UonfpBjCikHEQrJyE72Xw5eCY5A==}
dependencies:
@@ -4257,6 +4302,10 @@ packages:
dependencies:
ms: 2.1.2
/decimal.js@10.4.3:
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
dev: true
/dedent@0.7.0:
resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
dev: true
@@ -4284,7 +4333,6 @@ packages:
/delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dev: false
/dequal@2.0.2:
resolution: {integrity: sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug==}
@@ -4353,6 +4401,13 @@ packages:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
dev: false
/domexception@4.0.0:
resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==}
engines: {node: '>=12'}
dependencies:
webidl-conversions: 7.0.0
dev: true
/domhandler@4.3.1:
resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==}
engines: {node: '>= 4'}
@@ -4415,6 +4470,11 @@ packages:
engines: {node: '>=0.12'}
dev: false
/entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
dev: true
/error-ex@1.3.2:
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
dependencies:
@@ -5045,6 +5105,15 @@ packages:
mime-types: 2.1.34
dev: false
/form-data@4.0.0:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.34
dev: true
/fraction.js@4.2.0:
resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==}
dev: true
@@ -5328,6 +5397,17 @@ packages:
resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==}
dev: true
/happy-dom@9.18.3:
resolution: {integrity: sha512-b7iMGYeIXvUryNultA0AHEVU0FPpb2djJ/xSVlMDfP7HG4z7FomdqkCEpWtSv1zDL+t1gRUoBbpqFCoUBvjYtg==}
dependencies:
css.escape: 1.5.1
entities: 4.5.0
iconv-lite: 0.6.3
webidl-conversions: 7.0.0
whatwg-encoding: 2.0.0
whatwg-mimetype: 3.0.0
dev: true
/har-schema@2.0.0:
resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==}
engines: {node: '>=4'}
@@ -5365,6 +5445,13 @@ packages:
resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==}
dev: false
/html-encoding-sniffer@3.0.0:
resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==}
engines: {node: '>=12'}
dependencies:
whatwg-encoding: 2.0.0
dev: true
/html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
dev: true
@@ -5423,6 +5510,17 @@ packages:
parse-cache-control: 1.0.1
dev: false
/http-proxy-agent@5.0.0:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'}
dependencies:
'@tootallnate/once': 2.0.0
agent-base: 6.0.2
debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: true
/http-response-object@3.0.2:
resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==}
dependencies:
@@ -5438,6 +5536,16 @@ packages:
sshpk: 1.17.0
dev: false
/https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
dependencies:
agent-base: 6.0.2
debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: true
/human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@@ -5448,7 +5556,6 @@ packages:
engines: {node: '>=0.10.0'}
dependencies:
safer-buffer: 2.1.2
dev: false
/ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -5565,6 +5672,10 @@ packages:
engines: {node: '>=8'}
dev: true
/is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
dev: true
/is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
@@ -6136,6 +6247,44 @@ packages:
resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==}
dev: false
/jsdom@22.0.0:
resolution: {integrity: sha512-p5ZTEb5h+O+iU02t0GfEjAnkdYPrQSkfuTSMkMYyIoMvUNEHsbG0bHHbfXIcfTqD2UfvjQX7mmgiFsyRwGscVw==}
engines: {node: '>=16'}
peerDependencies:
canvas: ^2.5.0
peerDependenciesMeta:
canvas:
optional: true
dependencies:
abab: 2.0.6
cssstyle: 3.0.0
data-urls: 4.0.0
decimal.js: 10.4.3
domexception: 4.0.0
form-data: 4.0.0
html-encoding-sniffer: 3.0.0
http-proxy-agent: 5.0.0
https-proxy-agent: 5.0.1
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.4
parse5: 7.1.2
rrweb-cssom: 0.6.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 4.1.2
w3c-xmlserializer: 4.0.0
webidl-conversions: 7.0.0
whatwg-encoding: 2.0.0
whatwg-mimetype: 3.0.0
whatwg-url: 12.0.1
ws: 8.13.0
xml-name-validator: 4.0.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: true
/jsesc@2.5.2:
resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==}
engines: {node: '>=4'}
@@ -6171,7 +6320,6 @@ packages:
/jsonc-parser@3.2.0:
resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
dev: false
/jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
@@ -6515,14 +6663,12 @@ packages:
/mime-db@1.51.0:
resolution: {integrity: sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==}
engines: {node: '>= 0.6'}
dev: false
/mime-types@2.1.34:
resolution: {integrity: sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==}
engines: {node: '>= 0.6'}
dependencies:
mime-db: 1.51.0
dev: false
/mime@3.0.0:
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
@@ -6573,7 +6719,6 @@ packages:
pathe: 1.1.0
pkg-types: 1.0.3
ufo: 1.1.2
dev: false
/mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
@@ -6715,6 +6860,10 @@ packages:
resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==}
dev: false
/nwsapi@2.2.4:
resolution: {integrity: sha512-NHj4rzRo0tQdijE9ZqAx6kYDcoRwYwSYzCA8MY3JzfxlrvEU0jhnhJT9BhqhJs7I/dKcrDm6TyulaRqZPIhN5g==}
dev: true
/oauth-sign@0.9.0:
resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==}
dev: false
@@ -6860,6 +7009,12 @@ packages:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
/parse5@7.1.2:
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
dependencies:
entities: 4.5.0
dev: true
/path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
dev: false
@@ -6889,9 +7044,12 @@ packages:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
/pathe@0.2.0:
resolution: {integrity: sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw==}
dev: true
/pathe@1.1.0:
resolution: {integrity: sha512-ODbEPR0KKHqECXW1GoxdDb+AZvULmXjVPy4rt+pGo2+TnjJTIPJQSVS6N63n8T2Ip+syHhbn52OewKicV0373w==}
dev: false
/pathval@1.1.1:
resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==}
@@ -6928,7 +7086,6 @@ packages:
jsonc-parser: 3.2.0
mlly: 1.2.1
pathe: 1.1.0
dev: false
/plotly.js-dist-min@2.10.1:
resolution: {integrity: sha512-H0ls1C2uu2U+qWw76djo4/zOGtUKfMILwFhu7tCOaG/wH5ypujrYGCH03N9SQVf1SXcctTfW57USf8LmagSiPQ==}
@@ -7114,7 +7271,6 @@ packages:
/psl@1.9.0:
resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==}
dev: false
/punycode@2.3.0:
resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==}
@@ -7136,6 +7292,10 @@ packages:
engines: {node: '>=0.6'}
dev: false
/querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
dev: true
/queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -7252,6 +7412,10 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
dev: true
/resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
dev: false
@@ -7332,6 +7496,10 @@ packages:
optionalDependencies:
fsevents: 2.3.2
/rrweb-cssom@0.6.0:
resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==}
dev: true
/run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
dependencies:
@@ -7370,7 +7538,6 @@ packages:
/safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
dev: false
/sander@0.5.1:
resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==}
@@ -7389,6 +7556,13 @@ packages:
immutable: 4.3.0
source-map-js: 1.0.2
/saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
dependencies:
xmlchars: 2.2.0
dev: true
/semver@5.7.1:
resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==}
hasBin: true
@@ -7452,7 +7626,6 @@ packages:
/siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
dev: false
/sigmund@1.0.1:
resolution: {integrity: sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==}
@@ -7519,7 +7692,6 @@ packages:
dependencies:
buffer-from: 1.1.2
source-map: 0.6.1
dev: false
/source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
@@ -7577,7 +7749,6 @@ packages:
/stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
dev: false
/standardized-audio-context@25.3.45:
resolution: {integrity: sha512-d1UVvbz0mDmEqNehvoTKlpSevRJ3YiVZ6kdboeaWX+8cl94H1w8x7c5RNdg0nqxiE049LMeF4tFPuDl5Vm78Kg==}
@@ -7589,7 +7760,6 @@ packages:
/std-env@3.3.3:
resolution: {integrity: sha512-Rz6yejtVyWnVjC1RFvNmYL10kgjC49EOghxWn0RFqlCHGFpQx+Xe7yW3I4ceK1SGrWIGMjD5Kbue8W/udkbMJg==}
dev: false
/streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
@@ -7782,7 +7952,7 @@ packages:
resolution: {integrity: sha512-8Ifi5CD2Ui7FX7NjJRmutFtXjrB8T/FMNoS2H8P81t5LHK4I9G4NIs007rLWG/nRl7y+zJUXa3tWuTjYXw/O5A==}
dependencies:
'@floating-ui/core': 1.2.6
'@floating-ui/dom': 1.2.6
'@floating-ui/dom': 1.2.8
dev: false
/svelte-hmr@0.15.1(svelte@3.58.0):
@@ -7953,7 +8123,7 @@ packages:
/svelte-select@5.5.3:
resolution: {integrity: sha512-0QIiEmyon3bqoenFtR/BhamMpMmzOWWg1ctE7xxwP7nEnZhAGGoTra1HPPfEyT6C6gVaOFgCpdBaZoM8DEHlEw==}
dependencies:
'@floating-ui/dom': 1.2.6
'@floating-ui/dom': 1.2.8
svelte-floating-ui: 1.2.8
dev: false
@@ -8007,6 +8177,10 @@ packages:
ssr-window: 4.0.2
dev: false
/symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
dev: true
/sync-request@6.1.0:
resolution: {integrity: sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw==}
engines: {node: '>=8.0.0'}
@@ -8195,10 +8369,27 @@ packages:
punycode: 2.3.0
dev: false
/tough-cookie@4.1.2:
resolution: {integrity: sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==}
engines: {node: '>=6'}
dependencies:
psl: 1.9.0
punycode: 2.3.0
universalify: 0.2.0
url-parse: 1.5.10
dev: true
/tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: false
/tr46@4.1.1:
resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==}
engines: {node: '>=14'}
dependencies:
punycode: 2.3.0
dev: true
/ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@@ -8292,7 +8483,6 @@ packages:
/ufo@1.1.2:
resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==}
dev: false
/undici@5.20.0:
resolution: {integrity: sha512-J3j60dYzuo6Eevbawwp1sdg16k5Tf768bxYK4TUJRH7cBM4kFCbf3mOnM/0E3vQYXvpxITbbWmBafaDbxLDz3g==}
@@ -8306,6 +8496,11 @@ packages:
engines: {node: '>= 4.0.0'}
dev: false
/universalify@0.2.0:
resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
engines: {node: '>= 4.0.0'}
dev: true
/universalify@2.0.0:
resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==}
engines: {node: '>= 10.0.0'}
@@ -8325,6 +8520,13 @@ packages:
dependencies:
punycode: 2.3.0
/url-parse@1.5.10:
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
dependencies:
querystringify: 2.2.0
requires-port: 1.0.0
dev: true
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -8761,6 +8963,29 @@ packages:
extsprintf: 1.3.0
dev: false
/vite-node@0.27.3(@types/node@18.16.0)(sass@1.61.0):
resolution: {integrity: sha512-eyJYOO64o5HIp8poc4bJX+ZNBwMZeI3f6/JdiUmJgW02Mt7LnoCtDMRVmLaY9S05SIsjGe339ZK4uo2wQ+bF9g==}
engines: {node: '>=v14.16.0'}
hasBin: true
dependencies:
cac: 6.7.14
debug: 4.3.4
mlly: 1.2.1
pathe: 0.2.0
picocolors: 1.0.0
source-map: 0.6.1
source-map-support: 0.5.21
vite: 4.3.1(@types/node@18.16.0)(sass@1.61.0)
transitivePeerDependencies:
- '@types/node'
- less
- sass
- stylus
- sugarss
- supports-color
- terser
dev: true
/vite-node@0.31.0(@types/node@18.16.0):
resolution: {integrity: sha512-8x1x1LNuPvE2vIvkSB7c1mApX5oqlgsxzHQesYF7l5n1gKrEmrClIiZuOFbFDQcjLsmcWSwwmrWrcGWm9Fxc/g==}
engines: {node: '>=v14.18.0'}
@@ -9154,8 +9379,8 @@ packages:
vite: 4.3.1(sass@1.61.0)
dev: false
/vitest@0.25.8(sass@1.61.0):
resolution: {integrity: sha512-X75TApG2wZTJn299E/TIYevr4E9/nBo1sUtZzn0Ci5oK8qnpZAZyhwg0qCeMSakGIWtc6oRwcQFyFfW14aOFWg==}
/vitest@0.27.3(happy-dom@9.18.3)(jsdom@22.0.0)(sass@1.61.0):
resolution: {integrity: sha512-Ld3UVgRVhJUtqvQ3dW89GxiApFAgBsWJZBCWzK+gA3w2yG68csXlGZZ4WDJURf+8ecNfgrScga6xY+8YSOpiMg==}
engines: {node: '>=v14.16.0'}
hasBin: true
peerDependencies:
@@ -9181,15 +9406,22 @@ packages:
'@types/node': 18.16.0
acorn: 8.8.2
acorn-walk: 8.2.0
cac: 6.7.14
chai: 4.3.7
debug: 4.3.4
happy-dom: 9.18.3
jsdom: 22.0.0
local-pkg: 0.4.3
picocolors: 1.0.0
source-map: 0.6.1
std-env: 3.3.3
strip-literal: 1.0.1
tinybench: 2.4.0
tinypool: 0.3.1
tinyspy: 1.1.1
vite: 4.3.1(@types/node@18.16.0)(sass@1.61.0)
vite-node: 0.27.3(@types/node@18.16.0)(sass@1.61.0)
why-is-node-running: 2.2.2
transitivePeerDependencies:
- less
- sass
@@ -9308,6 +9540,13 @@ packages:
resolution: {integrity: sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg==}
dev: false
/w3c-xmlserializer@4.0.0:
resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==}
engines: {node: '>=14'}
dependencies:
xml-name-validator: 4.0.0
dev: true
/walker@1.0.8:
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
dependencies:
@@ -9322,11 +9561,36 @@ packages:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
dev: false
/webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
dev: true
/well-known-symbols@2.0.0:
resolution: {integrity: sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==}
engines: {node: '>=6'}
dev: false
/whatwg-encoding@2.0.0:
resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
engines: {node: '>=12'}
dependencies:
iconv-lite: 0.6.3
dev: true
/whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
dev: true
/whatwg-url@12.0.1:
resolution: {integrity: sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==}
engines: {node: '>=14'}
dependencies:
tr46: 4.1.1
webidl-conversions: 7.0.0
dev: true
/whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
dependencies:
@@ -9349,7 +9613,6 @@ packages:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
dev: false
/word-wrap@1.2.3:
resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==}
@@ -9405,7 +9668,15 @@ packages:
optional: true
utf-8-validate:
optional: true
dev: false
/xml-name-validator@4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
dev: true
/xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
dev: true
/xtend@2.2.0:
resolution: {integrity: sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==}

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
import { onMount } from 'svelte';
const app = new ComfyAppState();
export let app: ComfyAppState;
</script>
<ComfyApp {app}/>

View File

@@ -53,7 +53,6 @@
onMount(async () => {
await app.setup();
(window as any).app = app;
window.addEventListener("backbutton", onBackKeyDown, false);
window.addEventListener("popstate", onBackKeyDown, false);
});

View File

@@ -1,4 +1,4 @@
import { LConnectionKind, LGraph, LGraphNode, type INodeSlot, type SlotIndex, LiteGraph, getStaticProperty, type LGraphAddNodeOptions, LGraphCanvas, type LGraphRemoveNodeOptions } from "@litegraph-ts/core";
import { LConnectionKind, LGraph, LGraphNode, type INodeSlot, type SlotIndex, LiteGraph, getStaticProperty, type LGraphAddNodeOptions, LGraphCanvas, type LGraphRemoveNodeOptions, Subgraph, type LGraphAddNodeMode } from "@litegraph-ts/core";
import GraphSync from "./GraphSync";
import EventEmitter from "events";
import type TypedEmitter from "typed-emitter";
@@ -8,7 +8,8 @@ import { get } from "svelte/store";
import type ComfyGraphNode from "./nodes/ComfyGraphNode";
import type IComfyInputSlot from "./IComfyInputSlot";
import type { ComfyBackendNode } from "./nodes/ComfyBackendNode";
import type { ComfyComboNode, ComfyWidgetNode } from "./nodes";
import type { ComfyComboNode, ComfyWidgetNode } from "./nodes/widgets";
import selectionState from "./stores/selectionState";
type ComfyGraphEvents = {
configured: (graph: LGraph) => void
@@ -26,29 +27,48 @@ export default class ComfyGraph extends LGraph {
override onConfigure() {
console.debug("Configured");
this.eventBus.emit("configured", this);
}
override onBeforeChange(graph: LGraph, info: any) {
console.debug("BeforeChange", info);
this.eventBus.emit("beforeChange", graph, info);
}
override onAfterChange(graph: LGraph, info: any) {
console.debug("AfterChange", info);
this.eventBus.emit("afterChange", graph, info);
}
override onAfterExecute() {
this.eventBus.emit("afterExecute");
}
/*
* NOTE: This function will also be called by child subgraphs on their
* parent graphs. So we have to be sure the node that receives the callback
* is a root graph (this._is_subgraph is false). If a subgraph calls this
* then options.subgraphsh will have the list of subgraphs down the chain.
*/
override onNodeAdded(node: LGraphNode, options: LGraphAddNodeOptions) {
// Don't add nodes in subgraphs until this callback reaches the root
// graph
if (node.getRootGraph() == null || this._is_subgraph)
return;
this.doAddNode(node, options);
// console.debug("Added", node);
this.eventBus.emit("nodeAdded", node);
}
/*
* Add widget UI/groups for newly added nodes.
*/
private doAddNode(node: LGraphNode, options: LGraphAddNodeOptions) {
layoutState.nodeAdded(node, options)
// All nodes whether they come from base litegraph or ComfyBox should
// have tags added to them. Can't override serialization for existing
// node types to add `tags` as anew field so putting it in properties is better.
// node types to add `tags` as a new field so putting it in properties
// is better.
if (node.properties.tags == null)
node.properties.tags = []
@@ -88,7 +108,7 @@ export default class ComfyGraph extends LGraph {
if (!("svelteComponentType" in node) && options.addedBy == null) {
console.debug("[ComfyGraph] AutoAdd UI")
const comfyNode = node as ComfyGraphNode;
const widgetNodesAdded = []
const widgetNodesAdded: ComfyWidgetNode[] = []
for (let index = 0; index < comfyNode.inputs.length; index++) {
const input = comfyNode.inputs[index];
if ("config" in input) {
@@ -96,7 +116,7 @@ export default class ComfyGraph extends LGraph {
if (comfyInput.defaultWidgetNode) {
const widgetNode = LiteGraph.createNode(comfyInput.defaultWidgetNode)
const inputPos = comfyNode.getConnectionPos(true, index);
this.add(widgetNode)
node.graph.add(widgetNode)
widgetNode.connect(0, comfyNode, index);
widgetNode.collapse();
widgetNode.pos = [inputPos[0] - 140, inputPos[1] + LiteGraph.NODE_SLOT_HEIGHT / 2];
@@ -109,26 +129,44 @@ export default class ComfyGraph extends LGraph {
}
}
}
const dragItems = widgetNodesAdded.map(wn => get(layoutState).allItemsByNode[wn.id]?.dragItem).filter(di => di)
console.debug("[ComfyGraph] Group new widgets", dragItems)
const dragItemIDs = widgetNodesAdded.map(wn => get(layoutState).allItemsByNode[wn.id]?.dragItem?.id).filter(Boolean)
console.debug("[ComfyGraph] Group new widgets", dragItemIDs)
layoutState.groupItems(dragItems, { title: node.title })
// Use the default node title instead of custom node title, in
// case node was cloned
const reg = LiteGraph.registered_node_types[node.type]
layoutState.groupItems(dragItemIDs, { title: reg.title })
}
}
console.debug("Added", node);
this.eventBus.emit("nodeAdded", node);
// Handle nodes in subgraphs being attached to this graph indirectly
// ************** RECURSION ALERT ! **************
if (node.is(Subgraph)) {
for (const child of node.subgraph.iterateNodesInOrder()) {
this.doAddNode(child, options)
}
}
// ************** RECURSION ALERT ! **************
}
override onNodeRemoved(node: LGraphNode, options: LGraphRemoveNodeOptions) {
selectionState.clear(); // safest option
layoutState.nodeRemoved(node, options);
console.debug("Removed", node);
// Handle subgraphs being removed
if (node.is(Subgraph)) {
for (const child of node.subgraph.iterateNodesInOrder()) {
this.onNodeRemoved(child, options)
}
}
// console.debug("Removed", node);
this.eventBus.emit("nodeRemoved", node);
}
override onNodeConnectionChange(kind: LConnectionKind, node: LGraphNode, slot: SlotIndex, targetNode: LGraphNode, targetSlot: SlotIndex) {
console.debug("ConnectionChange", node);
// console.debug("ConnectionChange", node);
this.eventBus.emit("nodeConnectionChanged", kind, node, slot, targetNode, targetSlot);
}
}

View File

@@ -1,11 +1,13 @@
import { BuiltInSlotShape, LGraph, LGraphCanvas, LGraphNode, LiteGraph, NodeMode, type MouseEventExt, type Vector2, type Vector4, TitleMode, type ContextMenuItem, type IContextMenuItem, Subgraph, LLink } from "@litegraph-ts/core";
import { BuiltInSlotShape, LGraph, LGraphCanvas, LGraphNode, LiteGraph, NodeMode, type MouseEventExt, type Vector2, type Vector4, TitleMode, type ContextMenuItem, type IContextMenuItem, Subgraph, LLink, type NodeID } from "@litegraph-ts/core";
import type ComfyApp from "./components/ComfyApp";
import queueState from "./stores/queueState";
import { get } from "svelte/store";
import { get, type Unsubscriber } from "svelte/store";
import uiState from "./stores/uiState";
import layoutState from "./stores/layoutState";
import { Watch } from "@litegraph-ts/nodes-basic";
import { ComfyReroute } from "./nodes";
import type { Progress } from "./components/ComfyApp";
import selectionState from "./stores/selectionState";
export type SerializedGraphCanvasState = {
offset: Vector2,
@@ -14,6 +16,7 @@ export type SerializedGraphCanvasState = {
export default class ComfyGraphCanvas extends LGraphCanvas {
app: ComfyApp | null;
private _unsubscribe: Unsubscriber;
constructor(
app: ComfyApp,
@@ -27,13 +30,33 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
) {
super(canvas, app.lGraph, options);
this.app = app;
this._unsubscribe = selectionState.subscribe(ss => {
for (const node of Object.values(this.selected_nodes)) {
node.is_selected = false;
}
this.selected_nodes = {}
for (const node of ss.currentSelectionNodes) {
this.selected_nodes[node.id] = node;
node.is_selected = true
}
this._selectedNodes = new Set()
this.setDirty(true, true);
})
}
_selectedNodes: Set<NodeID> = new Set();
serialize(): SerializedGraphCanvasState {
return {
offset: this.ds.offset,
scale: this.ds.scale
let offset = this.ds.offset;
let scale = this.ds.scale;
// Use top-level graph for saved offset if we're viewing a subgraph
if (this._graph_stack?.length > 0) {
offset = this._graph_stack[0].offset;
scale = this._graph_stack[0].scale;
}
return { offset, scale }
}
deserialize(data: SerializedGraphCanvasState) {
@@ -58,52 +81,63 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
super.drawNodeShape(node, ctx, size, fgColor, bgColor, selected, mouseOver);
let state = get(queueState);
let ss = get(selectionState);
const isRunningNode = node.id === state.runningNodeID
let color = null;
if (node.id === +state.runningNodeID) {
let thickness = 1;
// if (this._selectedNodes.has(node.id)) {
// color = "yellow";
// thickness = 5;
// }
if (ss.currentHoveredNodes.has(node.id)) {
color = "lightblue";
}
else if (isRunningNode) {
color = "#0f0";
// this.app can be null inside the constructor if rendering is taking place already
} else if (this.app && this.app.dragOverNode && node.id === this.app.dragOverNode.id) {
color = "dodgerblue";
}
if (color) {
const shape = node.shape || BuiltInSlotShape.ROUND_SHAPE;
ctx.lineWidth = 1;
ctx.globalAlpha = 0.8;
ctx.beginPath();
if (shape == BuiltInSlotShape.BOX_SHAPE)
ctx.rect(-6, -6 + LiteGraph.NODE_TITLE_HEIGHT, 12 + size[0] + 1, 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT);
else if (shape == BuiltInSlotShape.ROUND_SHAPE || (shape == BuiltInSlotShape.CARD_SHAPE && node.flags.collapsed))
ctx.roundRect(
-6,
-6 - LiteGraph.NODE_TITLE_HEIGHT,
12 + size[0] + 1,
12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT,
this.round_radius * 2
);
else if (shape == BuiltInSlotShape.CARD_SHAPE)
ctx.roundRect(
-6,
-6 + LiteGraph.NODE_TITLE_HEIGHT,
12 + size[0] + 1,
12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT,
this.round_radius * 2,
2
);
else if (shape == BuiltInSlotShape.CIRCLE_SHAPE)
ctx.arc(size[0] * 0.5, size[1] * 0.5, size[0] * 0.5 + 6, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.stroke();
ctx.strokeStyle = fgColor;
ctx.globalAlpha = 1;
if (state.progress) {
ctx.fillStyle = "green";
ctx.fillRect(0, 0, size[0] * (state.progress.value / state.progress.max), 6);
ctx.fillStyle = bgColor;
}
this.drawNodeOutline(node, ctx, size, fgColor, bgColor, color, thickness)
}
if (isRunningNode && state.progress) {
ctx.fillStyle = "green";
ctx.fillRect(0, 0, size[0] * (state.progress.value / state.progress.max), 6);
ctx.fillStyle = bgColor;
}
}
private drawNodeOutline(node: LGraphNode, ctx: CanvasRenderingContext2D, size: Vector2, fgColor: string, bgColor: string, outlineColor: string, outlineThickness: number) {
const shape = node.shape || BuiltInSlotShape.ROUND_SHAPE;
ctx.lineWidth = outlineThickness;
ctx.globalAlpha = 0.8;
ctx.beginPath();
if (shape == BuiltInSlotShape.BOX_SHAPE)
ctx.rect(-6, -6 + LiteGraph.NODE_TITLE_HEIGHT, 12 + size[0] + 1, 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT);
else if (shape == BuiltInSlotShape.ROUND_SHAPE || (shape == BuiltInSlotShape.CARD_SHAPE && node.flags.collapsed))
ctx.roundRect(
-6,
-6 - LiteGraph.NODE_TITLE_HEIGHT,
12 + size[0] + 1,
12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT,
this.round_radius * 2
);
else if (shape == BuiltInSlotShape.CARD_SHAPE)
ctx.roundRect(
-6,
-6 + LiteGraph.NODE_TITLE_HEIGHT,
12 + size[0] + 1,
12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT,
this.round_radius * 2,
2
);
else if (shape == BuiltInSlotShape.CIRCLE_SHAPE)
ctx.arc(size[0] * 0.5, size[1] * 0.5, size[0] * 0.5 + 6, 0, Math.PI * 2);
ctx.strokeStyle = outlineColor;
ctx.stroke();
ctx.strokeStyle = fgColor;
ctx.globalAlpha = 1;
}
private alignToGrid(node: LGraphNode, ctx: CanvasRenderingContext2D) {
@@ -235,10 +269,43 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
}
override onSelectionChange(nodes: Record<number, LGraphNode>) {
const ls = get(layoutState)
ls.currentSelectionNodes = Object.values(nodes)
ls.currentSelection = []
layoutState.set(ls)
selectionState.update(ss => {
ss.currentSelectionNodes = Object.values(nodes)
ss.currentSelection = []
const ls = get(layoutState)
for (const node of ss.currentSelectionNodes) {
const widget = ls.allItemsByNode[node.id]
if (widget)
ss.currentSelection.push(widget.dragItem.id)
}
return ss
})
}
override onHoverChange(node: LGraphNode | null) {
selectionState.update(ss => {
ss.currentHoveredNodes.clear()
if (node) {
ss.currentHoveredNodes.add(node.id)
}
ss.currentHovered.clear()
const ls = get(layoutState)
for (const nodeID of ss.currentHoveredNodes) {
const widget = ls.allItemsByNode[nodeID]
if (widget)
ss.currentHovered.add(widget.dragItem.id)
}
return ss
})
}
override clear() {
super.clear();
selectionState.update(ss => {
ss.currentSelectionNodes = [];
ss.currentHoveredNodes.clear()
return ss;
})
}
override onNodeMoved(node: LGraphNode) {

60
src/lib/ComfyNodeDef.ts Normal file
View File

@@ -0,0 +1,60 @@
import { range } from "./utils"
import ComfyWidgets from "./widgets"
export type ComfyNodeDef = {
name: string
display_name?: string
category: string
input: ComfyNodeDefInputs
/** Output type like "LATENT" or "IMAGE" */
output: string[]
output_name: string[]
output_is_list: boolean[]
}
export type ComfyNodeDefInputs = {
required: Record<string, ComfyNodeDefInput>,
optional?: Record<string, ComfyNodeDefInput>
}
export type ComfyNodeDefInput = [ComfyNodeDefInputType, ComfyNodeDefInputOptions | null]
/**
* - Array: Combo widget. Usually the values are strings but they can also be other stuff like booleans.
* - "INT"/"FLOAT"/etc.: Non-combo type widgets. See ComfyWidgets type.
* - other string: Must be an input type, usually something lke "IMAGE" or "LATENT".
*/
export type ComfyNodeDefInputType = any[] | keyof typeof ComfyWidgets | string
export type ComfyNodeDefInputOptions = {
forceInput?: boolean
}
// TODO if/when comfy refactors
export type ComfyNodeDefOutput = {
type: string,
name: string,
is_list?: boolean
}
export function isBackendNodeDefInputType(inputName: string, type: ComfyNodeDefInputType): type is string {
return !Array.isArray(type) && !(type in ComfyWidgets) && !(`${type}:${inputName}` in ComfyWidgets);
}
export function iterateNodeDefInputs(def: ComfyNodeDef): Iterable<[string, ComfyNodeDefInput]> {
var inputs = def.input.required
if (def.input.optional != null) {
inputs = Object.assign({}, def.input.required, def.input.optional)
}
return Object.entries(inputs);
}
export function iterateNodeDefOutputs(def: ComfyNodeDef): Iterable<ComfyNodeDefOutput> {
return range(def.output.length).map(i => {
return {
type: def.output[i],
name: def.output_name[i] || def.output[i],
is_list: def.output_is_list[i],
}
})
}

View File

@@ -137,45 +137,22 @@ export class ImageViewer {
}
}
setupGalleryImageForLightbox(e: HTMLImageElement) {
if (e.dataset.modded === "true")
showLightbox(source: HTMLImageElement) {
const initiallyZoomed = true
this.modalZoomSet(this.modalImage, initiallyZoomed)
const galleryElem = source.closest<HTMLDivElement>("div.block")
console.debug("[ImageViewer] showModal", event, source, galleryElem);
if (!galleryElem || ImageViewer.all_gallery_buttons(galleryElem).length === 0) {
console.error("No buttons found on gallery element!", galleryElem)
return;
}
e.dataset.modded = "true";
e.style.cursor = 'pointer'
e.style.userSelect = 'none'
var isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1
// For Firefox, listening on click first switched to next image then shows the lightbox.
// If you know how to fix this without switching to mousedown event, please.
// For other browsers the event is click to make it possiblr to drag picture.
var event = isFirefox ? 'mousedown' : 'click'
e.addEventListener(event, (evt) => {
// if (!opts.js_modal_lightbox || evt.button != 0) return;
const initiallyZoomed = true
this.modalZoomSet(this.modalImage, initiallyZoomed)
evt.preventDefault()
const source = evt.target as HTMLImageElement;
const galleryElem = source.closest<HTMLDivElement>("div.block")
console.debug("[ImageViewer] showModal", event, source, galleryElem);
if (!galleryElem || ImageViewer.all_gallery_buttons(galleryElem).length === 0) {
console.error("No buttons found on gallery element!", galleryElem)
return;
}
let urls = ImageViewer.get_gallery_urls(galleryElem)
const [_currentButton, index] = ImageViewer.selected_gallery_button(galleryElem)
console.warn("Gallery!", index, urls, galleryElem)
this.showModal(urls, index, galleryElem)
evt.stopPropagation();
}, true);
let urls = ImageViewer.get_gallery_urls(galleryElem)
const [_currentButton, index] = ImageViewer.selected_gallery_button(galleryElem)
console.warn("Gallery!", index, urls, galleryElem)
this.showModal(urls, index, galleryElem)
}
modalZoomSet(modalImage: HTMLImageElement, enable: boolean) {

View File

@@ -1,9 +1,10 @@
import type { Progress, SerializedPrompt, SerializedPromptInputs, SerializedPromptInputsAll, SerializedPromptOutput, SerializedPromptOutputs } from "./components/ComfyApp";
import type { Progress, SerializedPrompt, SerializedPromptInputs, SerializedPromptInputsAll, SerializedPromptOutputs } from "./components/ComfyApp";
import type TypedEmitter from "typed-emitter";
import EventEmitter from "events";
import type { ComfyExecutionResult, ComfyImageLocation } from "./nodes/ComfyWidgetNodes";
import type { ComfyImageLocation } from "$lib/utils";
import type { SerializedLGraph, UUID } from "@litegraph-ts/core";
import type { SerializedLayoutState } from "./stores/layoutState";
import type { ComfyNodeDef } from "./ComfyNodeDef";
export type ComfyPromptRequest = {
client_id?: string,
@@ -30,7 +31,7 @@ export type ComfyAPIQueueResponse = {
error?: string
}
export type NodeID = UUID;
export type ComfyNodeID = UUID; // To distinguish from Litegraph NodeID
export type PromptID = UUID; // UUID
export type ComfyAPIHistoryItem = [
@@ -38,7 +39,7 @@ export type ComfyAPIHistoryItem = [
PromptID,
SerializedPromptInputsAll,
ComfyBoxPromptExtraData,
NodeID[] // good outputs
ComfyNodeID[] // good outputs
]
export type ComfyAPIPromptResponse = {
@@ -76,9 +77,10 @@ type ComfyAPIEvents = {
progress: (progress: Progress) => void,
reconnecting: () => void,
reconnected: () => void,
executing: (promptID: PromptID | null, runningNodeID: NodeID | null) => void,
executed: (promptID: PromptID, nodeID: NodeID, output: SerializedPromptOutput) => void,
execution_cached: (promptID: PromptID, nodes: NodeID[]) => void,
executing: (promptID: PromptID | null, runningNodeID: ComfyNodeID | null) => void,
executed: (promptID: PromptID, nodeID: ComfyNodeID, output: SerializedPromptOutputs) => void,
execution_start: (promptID: PromptID) => void,
execution_cached: (promptID: PromptID, nodes: ComfyNodeID[]) => void,
execution_error: (promptID: PromptID, message: string) => void,
}
@@ -182,6 +184,9 @@ export default class ComfyAPI {
case "executed":
this.eventBus.emit("executed", msg.data.prompt_id, msg.data.node, msg.data.output);
break;
case "execution_start":
this.eventBus.emit("execution_start", msg.data.prompt_id);
break;
case "execution_cached":
this.eventBus.emit("execution_cached", msg.data.prompt_id, msg.data.nodes);
break;
@@ -226,7 +231,7 @@ export default class ComfyAPI {
* Loads node object definitions for the graph
* @returns The node definitions
*/
async getNodeDefs(): Promise<any> {
async getNodeDefs(): Promise<Record<ComfyNodeID, ComfyNodeDef>> {
return fetch(this.getBackendUrl() + "/object_info", { cache: "no-store" })
.then(resp => resp.json())
}
@@ -248,7 +253,7 @@ export default class ComfyAPI {
postBody = JSON.stringify(body)
}
catch (error) {
return Promise.reject({ error })
return Promise.reject({ error: error.toString() })
}
return fetch(this.getBackendUrl() + "/prompt", {
@@ -260,12 +265,12 @@ export default class ComfyAPI {
})
.then(async (res) => {
if (res.status != 200) {
throw await res.text()
throw await res.json()
}
return res.json()
})
.then(raw => { return { promptID: raw.prompt_id } })
.catch(error => { return { error } })
.catch(error => { return error })
}
/**

View File

@@ -2,6 +2,7 @@
import { Block, BlockTitle } from "@gradio/atoms";
import Accordion from "$lib/components/gradio/app/Accordion.svelte";
import uiState from "$lib/stores/uiState";
import selectionState from "$lib/stores/selectionState";
import WidgetContainer from "./WidgetContainer.svelte"
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
@@ -60,10 +61,11 @@
}
</script>
{#if container && children}
{#if container && Array.isArray(children)}
{@const selected = $uiState.uiUnlocked && $selectionState.currentSelection.includes(container.id)}
<div class="container {container.attrs.direction} {container.attrs.classes} {classes.join(' ')} z-index{zIndex}"
class:hide-block={container.attrs.containerVariant === "hidden"}
class:selected={$uiState.uiUnlocked && $layoutState.currentSelection.includes(container.id)}
class:selected
class:root-container={zIndex === 0}
class:is-executing={container.isNodeExecuting}
class:edit={edit}>
@@ -120,6 +122,10 @@
<style lang="scss">
.container {
&.selected {
background: var(--comfy-container-selected-background-fill) !important;
}
> :global(*) {
border-radius: 0;
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { Block, BlockTitle } from "@gradio/atoms";
import uiState from "$lib/stores/uiState";
import selectionState from "$lib/stores/selectionState";
import WidgetContainer from "./WidgetContainer.svelte"
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
@@ -22,13 +23,21 @@
export let dragDisabled: boolean = false;
export let isMobile: boolean = false;
let attrsChanged: Writable<boolean> | null = null;
let attrsChanged: Writable<number> | null = null;
let children: IDragItem[] | null = null;
const flipDurationMs = 100;
$: if (container) {
children = $layoutState.allItems[container.id].children;
attrsChanged = container.attrsChanged
const entry = $layoutState.allItems[container.id]
if (entry) {
children = $layoutState.allItems[container.id].children;
attrsChanged = container.attrsChanged
}
else {
container = null;
children = null;
attrsChanged = null;
}
}
else {
children = null;
@@ -44,18 +53,17 @@
children = layoutState.updateChildren(container, evt.detail.items)
// Ensure dragging is stopped on drag finish
};
const tt = "asd\nasdlkj"
</script>
{#if container && children}
{#if container && Array.isArray(children)}
{@const selected = $uiState.uiUnlocked && $selectionState.currentSelection.includes(container.id)}
<div class="container {container.attrs.direction} {container.attrs.classes} {classes.join(' ')} z-index{zIndex}"
class:hide-block={container.attrs.containerVariant === "hidden"}
class:selected={$uiState.uiUnlocked && $layoutState.currentSelection.includes(container.id)}
class:selected
class:root-container={zIndex === 0}
class:is-executing={container.isNodeExecuting}
class:mobile={isMobile}
class:edit={edit}>
class:edit>
<Block>
{#if container.attrs.title && container.attrs.title !== ""}
<label for={String(container.id)} class={($uiState.uiUnlocked && $uiState.uiEditMode === "widgets") ? "edit-title-label" : ""}>
@@ -64,7 +72,7 @@
{/if}
<div class="v-pane"
class:empty={children.length === 0}
class:edit={edit}
class:edit
use:dndzone="{{
items: children,
flipDurationMs,
@@ -77,8 +85,9 @@
on:finalize="{handleFinalize}"
>
{#each children.filter(item => item.id !== SHADOW_PLACEHOLDER_ITEM_ID) as item(item.id)}
{@const hidden = isHidden(item)}
{@const hidden = isHidden(item) && !edit}
<div class="animation-wrapper"
class:edit
class:hidden={hidden}
animate:flip={{duration:flipDurationMs}}
style={item?.attrs?.style || ""}
@@ -111,17 +120,18 @@
.edit {
min-width: 200px;
margin: 0.2rem 0;
}
&:not(.edit) > .animation-wrapper.hidden {
.animation-wrapper.hidden:not(.edit) {
display: none;
}
&.empty {
border-width: 3px;
border-color: var(--color-grey-400);
border-radius: var(--block-radius);
background: var(--color-grey-300);
border-color: var(--comfy-container-empty-border-color);
border-radius: 0;
background: var(--comfy-container-empty-background-fill);
min-height: 100px;
border-style: dashed;
}
@@ -154,6 +164,10 @@
.container {
display: flex;
&.selected {
background: var(--comfy-container-selected-background-fill) !important;
}
> :global(*) {
border-radius: 0;
}
@@ -234,7 +248,7 @@
}
.handle-hidden {
background-color: #40404080;
background-color: #303030A0;
}
.handle-widget:hover {

View File

@@ -9,6 +9,7 @@
import { Checkbox, TextBox } from "@gradio/form"
import uiState from "$lib/stores/uiState";
import layoutState from "$lib/stores/layoutState";
import selectionState from "$lib/stores/selectionState";
import { ImageViewer } from "$lib/ImageViewer";
import type { ComfyAPIStatus } from "$lib/api";
import { SvelteToast, toast } from '@zerodevx/svelte-toast'
@@ -54,12 +55,12 @@
if (!$uiState.uiUnlocked) {
app.lCanvas.deselectAllNodes();
$layoutState.currentSelectionNodes = []
$selectionState.currentSelectionNodes = []
}
}
$: if ($uiState.uiEditMode)
$layoutState.currentSelection = []
$selectionState.currentSelection = []
let graphSize = 0;
let graphTransitioning = false;
@@ -171,8 +172,6 @@
onMount(async () => {
await app.setup();
(window as any).app = app;
(window as any).appPane = uiPane;
// await import('../../scss/ux.scss');
@@ -184,10 +183,10 @@
}
$: if (uiTheme === "gradio-dark") {
document.getElementById("app").classList.add("dark")
document.getElementById("app-root").classList.add("dark")
}
else {
document.getElementById("app").classList.remove("dark")
document.getElementById("app-root").classList.remove("dark")
}
</script>

View File

@@ -1,6 +1,6 @@
import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType, type INodeInputSlot } from "@litegraph-ts/core";
import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType, type INodeInputSlot, type NodeID, type NodeTypeSpec, type NodeTypeOpts, type SlotIndex } from "@litegraph-ts/core";
import type { LConnectionKind, INodeSlot } from "@litegraph-ts/core";
import ComfyAPI, { type ComfyAPIStatusResponse, type ComfyBoxPromptExtraData, type ComfyPromptRequest, type NodeID, type PromptID } from "$lib/api"
import ComfyAPI, { type ComfyAPIStatusResponse, type ComfyBoxPromptExtraData, type ComfyPromptRequest, type ComfyNodeID, type PromptID } from "$lib/api"
import { getPngMetadata, importA1111 } from "$lib/pnginfo";
import EventEmitter from "events";
import type TypedEmitter from "typed-emitter";
@@ -12,11 +12,12 @@ import "@litegraph-ts/nodes-logic"
import "@litegraph-ts/nodes-math"
import "@litegraph-ts/nodes-strings"
import "$lib/nodes/index"
import "$lib/nodes/widgets/index"
import * as nodes from "$lib/nodes/index"
import * as widgets from "$lib/nodes/widgets/index"
import ComfyGraphCanvas, { type SerializedGraphCanvasState } from "$lib/ComfyGraphCanvas";
import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode";
import * as widgets from "$lib/widgets/index"
import queueState from "$lib/stores/queueState";
import { type SvelteComponentDev } from "svelte/internal";
import type IComfyInputSlot from "$lib/IComfyInputSlot";
@@ -28,18 +29,17 @@ import { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode";
import { get } from "svelte/store";
import { tick } from "svelte";
import uiState from "$lib/stores/uiState";
import { download, jsonToJsObject, promptToGraphVis, range, workflowToGraphVis } from "$lib/utils";
import { download, graphToGraphVis, jsonToJsObject, promptToGraphVis, range, workflowToGraphVis } from "$lib/utils";
import notify from "$lib/notify";
import configState from "$lib/stores/configState";
import { blankGraph } from "$lib/defaultGraph";
import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes";
import type { ComfyExecutionResult } from "$lib/utils";
import ComfyPromptSerializer, { UpstreamNodeLocator, isActiveBackendNode } from "./ComfyPromptSerializer";
import { iterateNodeDefInputs, type ComfyNodeDef, isBackendNodeDefInputType, iterateNodeDefOutputs } from "$lib/ComfyNodeDef";
import { ComfyComboNode } from "$lib/nodes/widgets";
export const COMFYBOX_SERIAL_VERSION = 1;
LiteGraph.catch_exceptions = false;
LiteGraph.CANVAS_GRID_SIZE = 32;
LiteGraph.default_subgraph_lgraph_factory = () => new ComfyGraph();
if (typeof window !== "undefined") {
// Load default visibility
nodes.ComfyReroute.setDefaultTextVisibility(!!localStorage["Comfy.ComfyReroute.DefaultVisibility"]);
@@ -56,7 +56,7 @@ export type SerializedAppState = {
}
/** [link origin, link index] | value */
export type SerializedPromptInput = [NodeID, number] | any
export type SerializedPromptInput = [ComfyNodeID, number] | any
export type SerializedPromptInputs = {
/* property name -> value or link */
@@ -64,14 +64,14 @@ export type SerializedPromptInputs = {
class_type: string
}
export type SerializedPromptInputsAll = Record<NodeID, SerializedPromptInputs>
export type SerializedPromptInputsAll = Record<ComfyNodeID, SerializedPromptInputs>
export type SerializedPrompt = {
workflow: SerializedLGraph,
output: SerializedPromptInputsAll
}
export type SerializedPromptOutputs = Record<NodeID, ComfyExecutionResult>
export type SerializedPromptOutputs = Record<ComfyNodeID, ComfyExecutionResult>
export type Progress = {
value: number,
@@ -79,32 +79,11 @@ export type Progress = {
}
type BackendComboNode = {
comboNode: nodes.ComfyComboNode
inputSlot: IComfyInputSlot,
comboNode: ComfyComboNode,
comfyInput: IComfyInputSlot,
backendNode: ComfyBackendNode
}
function isActiveBackendNode(node: ComfyGraphNode, tag: string | null): boolean {
if (!node.isBackendNode)
return false;
if (tag && !hasTag(node, tag)) {
console.debug("Skipping tagged node", tag, node.properties.tags, node)
return false;
}
if (node.mode === NodeMode.NEVER) {
// Don't serialize muted nodes
return false;
}
return true;
}
function hasTag(node: LGraphNode, tag: string): boolean {
return "tags" in node.properties && node.properties.tags.indexOf(tag) !== -1
}
export default class ComfyApp {
api: ComfyAPI;
rootEl: HTMLDivElement | null = null;
@@ -115,16 +94,17 @@ export default class ComfyApp {
dropZone: HTMLElement | null = null;
nodeOutputs: Record<string, any> = {};
dragOverNode: LGraphNode | null = null;
shiftDown: boolean = false;
selectedGroupMoving: boolean = false;
private queueItems: QueueItem[] = [];
private processingQueue: boolean = false;
private alreadySetup = false;
private promptSerializer: ComfyPromptSerializer;
constructor() {
this.api = new ComfyAPI();
this.promptSerializer = new ComfyPromptSerializer();
}
async setup(): Promise<void> {
@@ -135,21 +115,16 @@ export default class ComfyApp {
this.setupColorScheme()
this.rootEl = document.getElementById("app") as HTMLDivElement;
this.rootEl = document.getElementById("app-root") as HTMLDivElement;
this.canvasEl = document.getElementById("graph-canvas") as HTMLCanvasElement;
this.lGraph = new ComfyGraph();
this.lCanvas = new ComfyGraphCanvas(this, this.canvasEl);
this.canvasCtx = this.canvasEl.getContext("2d");
LiteGraph.release_link_on_empty_shows_menu = true;
LiteGraph.alt_drag_do_clone_nodes = true;
const uiUnlocked = get(uiState).uiUnlocked;
this.lCanvas.allow_dragnodes = uiUnlocked;
this.lCanvas.allow_interaction = uiUnlocked;
(window as any).LiteGraph = LiteGraph;
// await this.#invokeExtensionsAsync("init");
await this.registerNodes();
@@ -223,15 +198,11 @@ export default class ComfyApp {
static widget_type_overrides: Record<string, typeof SvelteComponentDev> = {}
private async registerNodes() {
const app = this;
// Load node definitions from the backend
const defs = await this.api.getNodeDefs();
// Register a node for each definition
for (const nodeId in defs) {
const nodeData = defs[nodeId];
for (const [nodeId, nodeDef] of Object.entries(defs)) {
const typeOverride = ComfyApp.node_type_overrides[nodeId]
if (typeOverride)
console.debug("Attaching custom type to received node:", nodeId, typeOverride)
@@ -239,19 +210,79 @@ export default class ComfyApp {
const ctor = class extends baseClass {
constructor(title?: string) {
super(title, nodeId, nodeData);
super(title, nodeId, nodeDef);
}
}
const node: LGraphNodeConstructor = {
class: ctor,
title: nodeData.display_name || nodeData.name,
title: nodeDef.display_name || nodeDef.name,
type: nodeId,
desc: `ComfyNode: ${nodeId}`
}
LiteGraph.registerNodeType(node);
node.category = nodeData.category;
node.category = nodeDef.category;
ComfyApp.registerDefaultSlotHandlers(nodeId, nodeDef)
}
}
static registerDefaultSlotHandlers(nodeId: string, nodeDef: ComfyNodeDef) {
const nodeTypeSpec: NodeTypeOpts = {
node: nodeId,
title: nodeDef.display_name || nodeDef.name,
properties: null,
inputs: null,
outputs: null
}
for (const [inputName, [inputType, _inputOpts]] of iterateNodeDefInputs(nodeDef)) {
if (isBackendNodeDefInputType(inputName, inputType)) {
LiteGraph.slot_types_default_out[inputType] ||= ["utils/reroute"]
LiteGraph.slot_types_default_out[inputType].push(nodeTypeSpec)
// Input types have to be stored as lower case
// Store each node that can handle this input type
const lowerType = inputType.toLocaleLowerCase();
if (!(lowerType in LiteGraph.registered_slot_in_types)) {
LiteGraph.registered_slot_in_types[lowerType] = { nodes: [] };
}
LiteGraph.registered_slot_in_types[lowerType].nodes.push(nodeId);
if (!LiteGraph.slot_types_in.includes(lowerType)) {
LiteGraph.slot_types_in.push(lowerType);
}
}
else {
// inputType is an array of combo box entries (["euler", "karras", ...])
// or a widget input type ("INT").
}
}
for (const output of iterateNodeDefOutputs(nodeDef)) {
LiteGraph.slot_types_default_in[output.type] ||= ["utils/reroute"]
LiteGraph.slot_types_default_in[output.type].push(nodeTypeSpec)
// Store each node that can handle this output type
if (!(output.type in LiteGraph.registered_slot_out_types)) {
LiteGraph.registered_slot_out_types[output.type] = { nodes: [] };
}
LiteGraph.registered_slot_out_types[output.type].nodes.push(nodeId);
if (!LiteGraph.slot_types_out.includes(output.type)) {
LiteGraph.slot_types_out.push(output.type);
}
}
const maxNodeSuggestions = 5 // TODO config
// TODO config beforeChanged
for (const key of Object.keys(LiteGraph.slot_types_default_in)) {
LiteGraph.slot_types_default_in[key] = LiteGraph.slot_types_default_in[key].slice(0, maxNodeSuggestions)
}
for (const key of Object.keys(LiteGraph.slot_types_default_out)) {
LiteGraph.slot_types_default_out[key] = LiteGraph.slot_types_default_out[key].slice(0, maxNodeSuggestions)
}
}
@@ -342,21 +373,25 @@ export default class ComfyApp {
this.lGraph.setDirtyCanvas(true, false);
});
this.api.addEventListener("executing", (promptID: PromptID | null, nodeID: NodeID | null) => {
this.api.addEventListener("executing", (promptID: PromptID | null, nodeID: ComfyNodeID | null) => {
queueState.executingUpdated(promptID, nodeID);
this.lGraph.setDirtyCanvas(true, false);
});
this.api.addEventListener("executed", (promptID: PromptID, nodeID: NodeID, output: ComfyExecutionResult) => {
this.api.addEventListener("executed", (promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) => {
this.nodeOutputs[nodeID] = output;
const node = this.lGraph.getNodeById(nodeID) as ComfyGraphNode;
const node = this.lGraph.getNodeByIdRecursive(nodeID) as ComfyGraphNode;
if (node?.onExecuted) {
node.onExecuted(output);
}
queueState.onExecuted(promptID, nodeID, output)
});
this.api.addEventListener("execution_cached", (promptID: PromptID, nodes: NodeID[]) => {
this.api.addEventListener("execution_start", (promptID: PromptID) => {
queueState.executionStart(promptID)
});
this.api.addEventListener("execution_cached", (promptID: PromptID, nodes: ComfyNodeID[]) => {
queueState.executionCached(promptID, nodes)
});
@@ -403,7 +438,7 @@ export default class ComfyApp {
}
// Distinguish frontend/backend connections
const BACKEND_TYPES = ["CLIP", "CLIP_VISION", "CLIP_VISION_OUTPUT", "CONDITIONING", "CONTROL_NET", "LATENT", "MASK", "MODEL", "STYLE_MODEL", "VAE", "UPSCALE_MODEL"]
const BACKEND_TYPES = ["CLIP", "CLIP_VISION", "CLIP_VISION_OUTPUT", "CONDITIONING", "CONTROL_NET", "IMAGE", "LATENT", "MASK", "MODEL", "STYLE_MODEL", "VAE", "UPSCALE_MODEL"]
for (const type of BACKEND_TYPES) {
setColor(type, "orange")
}
@@ -508,6 +543,7 @@ export default class ComfyApp {
}
layoutState.onStartConfigure();
this.lCanvas.closeAllSubgraphs();
this.lGraph.configure(blankGraph)
layoutState.initDefaultLayout();
uiState.update(s => {
@@ -518,7 +554,7 @@ export default class ComfyApp {
}
runDefaultQueueAction() {
for (const node of this.lGraph.iterateNodesInOrder()) {
for (const node of this.lGraph.iterateNodesInOrderRecursive()) {
if ("onDefaultQueueAction" in node) {
(node as ComfyGraphNode).onDefaultQueueAction()
}
@@ -559,157 +595,7 @@ export default class ComfyApp {
* @returns The workflow and node links
*/
graphToPrompt(tag: string | null = null): SerializedPrompt {
// Run frontend-only logic
this.lGraph.runStep(1)
const workflow = this.lGraph.serialize();
const output = {};
// Process nodes in order of execution
for (const node_ of this.lGraph.computeExecutionOrder<ComfyGraphNode>(false, null)) {
const n = workflow.nodes.find((n) => n.id === node_.id);
if (!isActiveBackendNode(node_, tag)) {
continue;
}
const node = node_ as ComfyBackendNode;
const inputs = {};
// Store input values passed by frontend-only nodes
if (node.inputs) {
for (let i = 0; i < node.inputs.length; i++) {
const inp = node.inputs[i];
const inputLink = node.getInputLink(i)
const inputNode = node.getInputNode(i)
// We don't check tags for non-backend nodes.
// Just check for node inactivity (so you can toggle groups of
// tagged frontend nodes on/off)
if (inputNode && inputNode.mode === NodeMode.NEVER) {
console.debug("Skipping inactive node", inputNode)
continue;
}
if (!inputLink || !inputNode) {
if ("config" in inp) {
const defaultValue = (inp as IComfyInputSlot).config?.defaultValue
if (defaultValue !== null && defaultValue !== undefined)
inputs[inp.name] = defaultValue
}
continue;
}
let serialize = true;
if ("config" in inp)
serialize = (inp as IComfyInputSlot).serialize
let isBackendNode = node.isBackendNode;
let isInputBackendNode = false;
if ("isBackendNode" in inputNode)
isInputBackendNode = (inputNode as ComfyGraphNode).isBackendNode;
// The reasoning behind this check:
// We only want to serialize inputs to nodes with backend equivalents.
// And in ComfyBox, the backend nodes in litegraph *never* have widgets, instead they're all inputs.
// All values are passed by separate frontend-only nodes,
// either UI-bound or something like ConstantInteger.
// So we know that any value passed into a backend node *must* come from
// a frontend node.
// The rest (links between backend nodes) will be serialized after this bit runs.
if (serialize && isBackendNode && !isInputBackendNode) {
inputs[inp.name] = inputLink.data
}
}
}
// Store links between backend-only and hybrid nodes
for (let i = 0; i < node.inputs.length; i++) {
let parent: ComfyGraphNode = node.getInputNode(i) as ComfyGraphNode;
if (parent) {
const seen = {}
let link = node.getInputLink(i);
const isFrontendParent = (parent: ComfyGraphNode) => {
if (!parent || parent.isBackendNode)
return false;
if (tag && !hasTag(parent, tag))
return false;
return true;
}
// If there are frontend-only nodes between us and another
// backend node, we have to traverse them first. This
// behavior is dependent on the type of node. Reroute nodes
// will simply follow their single input, while branching
// nodes have conditional logic that determines which link
// to follow backwards.
while (isFrontendParent(parent)) {
if (!("getUpstreamLink" in parent)) {
console.warn("[graphToPrompt] Node does not support getUpstreamLink", parent.type)
break;
}
const nextLink = parent.getUpstreamLink()
if (nextLink == null) {
console.warn("[graphToPrompt] No upstream link found in frontend node", parent)
break;
}
if (nextLink && !seen[nextLink.id]) {
seen[nextLink.id] = true
const inputNode = parent.graph.getNodeById(nextLink.origin_id) as ComfyGraphNode;
if (inputNode && tag && !hasTag(inputNode, tag)) {
console.debug("[graphToPrompt] Skipping tagged intermediate frontend node", tag, node.properties.tags)
parent = null;
}
else {
console.debug("[graphToPrompt] Traverse upstream link", parent.id, inputNode?.id, inputNode?.isBackendNode)
link = nextLink;
parent = inputNode;
}
} else {
parent = null;
}
}
if (link && parent && parent.isBackendNode) {
if (tag && !hasTag(parent, tag))
continue;
console.debug("[graphToPrompt] final link", parent.id, node.id)
const input = node.inputs[i]
// TODO can null be a legitimate value in some cases?
// Nodes like CLIPLoader will never have a value in the frontend, hence "null".
if (!(input.name in inputs))
inputs[input.name] = [String(link.origin_id), link.origin_slot];
}
}
}
output[String(node.id)] = {
inputs,
class_type: node.comfyClass,
};
}
// Remove inputs connected to removed nodes
for (const nodeId in output) {
for (const inputName in output[nodeId].inputs) {
if (Array.isArray(output[nodeId].inputs[inputName])
&& output[nodeId].inputs[inputName].length === 2
&& !output[output[nodeId].inputs[inputName][0]]) {
console.debug("Prune removed node link", nodeId, inputName, output[nodeId].inputs[inputName])
delete output[nodeId].inputs[inputName];
}
}
}
// console.debug({ workflow, output })
// console.debug(promptToGraphVis({ workflow, output }))
return { workflow, output };
return this.promptSerializer.serialize(this.lGraph, tag)
}
async queuePrompt(num: number, batchCount: number = 1, tag: string | null = null) {
@@ -756,6 +642,7 @@ export default class ComfyApp {
const p = this.graphToPrompt(tag);
const l = layoutState.serialize();
console.debug(graphToGraphVis(this.lGraph))
console.debug(promptToGraphVis(p))
const extraData: ComfyBoxPromptExtraData = {
@@ -767,8 +654,8 @@ export default class ComfyApp {
thumbnails
}
let error = null;
let promptID = null;
let error: string | null = null;
let promptID: PromptID | null = null;
const request: ComfyPromptRequest = {
number: num,
@@ -778,28 +665,26 @@ export default class ComfyApp {
try {
const response = await this.api.queuePrompt(request);
// BUG: This can cause race conditions updating frontend state
// since we don't have a prompt ID until the backend
// returns!
promptID = response.promptID;
queueState.afterQueued(promptID, num, p.output, extraData)
error = response.error;
if (response.error != null) {
error = response.error;
}
else {
queueState.afterQueued(response.promptID, num, p.output, extraData)
}
} catch (err) {
error = err
error = err?.toString();
}
if (error != null) {
const mes = error.response || error.toString()
const mes: string = error;
notify(`Error queuing prompt:\n${mes}`, { type: "error" })
console.error(graphToGraphVis(this.lGraph))
console.error(promptToGraphVis(p))
console.error("Error queuing prompt", error, num, p)
break;
}
for (const n of p.workflow.nodes) {
const node = this.lGraph.getNodeById(n.id);
for (const node of this.lGraph.iterateNodesInOrderRecursive()) {
if ("afterQueued" in node) {
(node as ComfyGraphNode).afterQueued(p, tag);
}
@@ -857,98 +742,109 @@ export default class ComfyApp {
async refreshComboInNodes(flashUI: boolean = false) {
const defs = await this.api.getNodeDefs();
const toUpdate: BackendComboNode[] = []
const isComfyComboNode = (node: LGraphNode): boolean => {
const isComfyComboNode = (node: LGraphNode): node is ComfyComboNode => {
return node
&& node.type === "ui/combo"
&& "doAutoConfig" in node;
}
const isComfyComboInput = (input: INodeInputSlot) => {
const isComfyComboInput = (input: INodeInputSlot): input is IComfyInputSlot => {
return "config" in input
&& "widgetNodeType" in input
&& input.widgetNodeType === "ui/combo";
}
// Node IDs of combo widgets attached to a backend node
let backendCombos: Set<number> = new Set()
const backendUpdatedCombos: Record<NodeID, BackendComboNode> = {}
console.debug("[refreshComboInNodes] start")
// Figure out which combo nodes to update. They need to be connected to
// an input slot on a backend node with a backend config in the input
// slot connected to.
for (const node of this.lGraph.iterateNodesInOrder()) {
if (!(node as any).isBackendNode)
const nodeLocator = new UpstreamNodeLocator(isComfyComboNode)
const findComfyInputAndAttachedCombo = (node: LGraphNode, i: SlotIndex): [IComfyInputSlot, ComfyComboNode] | null => {
const input = node.inputs[i]
// Does this input autocreate a combo box on creation?
const isComfyInput = isComfyComboInput(input)
if (!isComfyInput)
return null;
// Find an attached combo node even if it's inside/outside of a
// subgraph, linked after several nodes, etc.
const [comboNode, _link] = nodeLocator.locateUpstream(node, i, null);
if (comboNode == null)
return null;
const result: [IComfyInputSlot, ComfyComboNode] = [input, comboNode as ComfyComboNode]
return result
}
for (const node of this.lGraph.iterateNodesInOrderRecursive()) {
if (!isActiveBackendNode(node))
continue;
const backendNode = (node as ComfyBackendNode)
const found = range(backendNode.inputs.length)
.filter(i => {
const input = backendNode.inputs[i]
const inputNode = backendNode.getInputNode(i)
const found = range(node.inputs.length)
.map((i) => findComfyInputAndAttachedCombo(node, i))
.filter(Boolean);
// Does this input autocreate a combo box on creation?
const isComfyInput = isComfyComboInput(input)
const isComfyCombo = isComfyComboNode(inputNode)
for (const [comfyInput, comboNode] of found) {
const def = defs[node.type];
// console.debug("[refreshComboInNodes] CHECK", backendNode.type, input.name, "isComfyCombo", isComfyCombo, "isComfyInput", isComfyInput)
return isComfyCombo && isComfyInput
});
for (const inputIndex of found) {
const comboNode = backendNode.getInputNode(inputIndex) as nodes.ComfyComboNode
const inputSlot = backendNode.inputs[inputIndex] as IComfyInputSlot;
const def = defs[backendNode.type];
const hasBackendConfig = def["input"]["required"][inputSlot.name] !== undefined
const hasBackendConfig = def["input"]["required"][comfyInput.name] !== undefined
if (hasBackendConfig) {
backendCombos.add(comboNode.id)
toUpdate.push({ comboNode, inputSlot, backendNode })
backendUpdatedCombos[comboNode.id] = { comboNode, comfyInput, backendNode: node }
}
}
}
console.debug("[refreshComboInNodes] found:", toUpdate.length, toUpdate)
console.debug("[refreshComboInNodes] found:", backendUpdatedCombos.length, backendUpdatedCombos)
// Mark combo nodes without backend configs as being loaded already.
for (const node of this.lGraph.iterateNodesInOrder()) {
if (isComfyComboNode(node) && !backendCombos.has(node.id)) {
const comboNode = node as nodes.ComfyComboNode;
let values = comboNode.properties.values;
// Frontend nodes can declare defaultWidgets which creates a
// config inside their own inputs slots too.
const foundInput = range(node.outputs.length)
.flatMap(i => node.getInputSlotsConnectedTo(i))
.find(inp => "config" in inp && Array.isArray((inp.config as any).values))
if (foundInput != null) {
const comfyInput = foundInput as IComfyInputSlot;
console.warn("[refreshComboInNodes] found frontend config:", node.title, node.type, comfyInput.config.values)
values = comfyInput.config.values;
}
comboNode.formatValues(values);
for (const node of this.lGraph.iterateNodesOfClassRecursive(ComfyComboNode)) {
if (backendUpdatedCombos[node.id] != null) {
continue;
}
// This node isn't connected to a backend node, so it's configured
// by the frontend instead.
const comboNode = node as ComfyComboNode;
let values = comboNode.properties.values;
// Frontend nodes can declare defaultWidgets which creates a
// config inside their own inputs slots too.
const foundInput = range(node.outputs.length)
.flatMap(i => node.getInputSlotsConnectedTo(i))
.find(inp => "config" in inp && Array.isArray((inp.config as any).values))
let defaultValue = null;
if (foundInput != null) {
const comfyInput = foundInput as IComfyInputSlot;
console.warn("[refreshComboInNodes] found frontend config:", node.title, node.type, comfyInput.config.values)
values = comfyInput.config.values;
defaultValue = comfyInput.config.defaultValue;
}
comboNode.formatValues(values, defaultValue);
}
await tick();
// Load definitions from the backend.
for (const { comboNode, inputSlot, backendNode } of toUpdate) {
for (const { comboNode, comfyInput, backendNode } of Object.values(backendUpdatedCombos)) {
const def = defs[backendNode.type];
const rawValues = def["input"]["required"][inputSlot.name][0];
const rawValues = def["input"]["required"][comfyInput.name][0];
console.debug("[ComfyApp] Reconfiguring combo widget", backendNode.type, "=>", comboNode.type, rawValues.length)
comboNode.doAutoConfig(inputSlot, { includeProperties: new Set(["values"]), setWidgetTitle: false })
comboNode.doAutoConfig(comfyInput, { includeProperties: new Set(["values"]), setWidgetTitle: false })
comboNode.formatValues(rawValues)
comboNode.formatValues(rawValues as string[], true)
if (!rawValues?.includes(get(comboNode.value))) {
comboNode.setValue(rawValues[0])
comboNode.setValue(rawValues[0], comfyInput.config.defaultValue)
}
}
}

View File

@@ -45,6 +45,11 @@
}
}
}
input {
color: var(--body-text-color);
background: var(--input-background-fill);
border: var(--input-border-width) solid var(--input-border-color)
}
input[disabled] {
cursor: not-allowed;
}

View File

@@ -0,0 +1,274 @@
import type ComfyGraph from "$lib/ComfyGraph";
import type { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode";
import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode";
import { GraphInput, GraphOutput, LGraph, LGraphNode, LLink, NodeMode, Subgraph, type SlotIndex } from "@litegraph-ts/core";
import type { SerializedPrompt, SerializedPromptInput, SerializedPromptInputs, SerializedPromptInputsAll } from "./ComfyApp";
import type IComfyInputSlot from "$lib/IComfyInputSlot";
function hasTag(node: LGraphNode, tag: string): boolean {
return "tags" in node.properties && node.properties.tags.indexOf(tag) !== -1
}
function isGraphInputOutput(node: LGraphNode): boolean {
return node.is(GraphInput) || node.is(GraphOutput)
}
export function isActiveNode(node: LGraphNode, tag: string | null = null): boolean {
if (!node)
return false;
// Check tags but not on graph inputs/outputs
if (!isGraphInputOutput(node) && (tag && !hasTag(node, tag))) {
console.debug("Skipping tagged node", tag, node.properties.tags, node)
return false;
}
if (node.mode !== NodeMode.ALWAYS) {
// Don't serialize muted nodes
return false;
}
return true;
}
export function isActiveBackendNode(node: LGraphNode, tag: string | null = null): node is ComfyBackendNode {
if (!(node as any).isBackendNode)
return false;
return isActiveNode(node, tag);
}
export class UpstreamNodeLocator {
constructor(private isTheTargetNode: (node: LGraphNode) => boolean) {
}
private followSubgraph(subgraph: Subgraph, link: LLink): [LGraph | null, LLink | null] {
if (link.origin_id != subgraph.id)
throw new Error("Invalid link and graph output!")
const innerGraphOutput = subgraph.getInnerGraphOutputByIndex(link.origin_slot)
if (innerGraphOutput == null)
throw new Error("No inner graph input!")
const nextLink = innerGraphOutput.getInputLink(0)
return [innerGraphOutput.graph, nextLink];
}
private followGraphInput(graphInput: GraphInput, link: LLink): [LGraph | null, LLink | null] {
if (link.origin_id != graphInput.id)
throw new Error("Invalid link and graph input!")
const outerSubgraph = graphInput.getParentSubgraph();
if (outerSubgraph == null)
throw new Error("No outer subgraph!")
const outerInputIndex = outerSubgraph.inputs.findIndex(i => i.name === graphInput.nameInGraph)
if (outerInputIndex == null)
throw new Error("No outer input slot!")
const nextLink = outerSubgraph.getInputLink(outerInputIndex)
return [outerSubgraph.graph, nextLink];
}
private getUpstreamLink(parent: LGraphNode, currentLink: LLink): [LGraph | null, LLink | null] {
if (parent.is(Subgraph)) {
console.debug("FollowSubgraph")
return this.followSubgraph(parent, currentLink);
}
else if (parent.is(GraphInput)) {
console.debug("FollowGraphInput")
return this.followGraphInput(parent, currentLink);
}
else if ("getUpstreamLink" in parent) {
return [parent.graph, (parent as ComfyGraphNode).getUpstreamLink()];
}
else if (parent.inputs.length === 1) {
// Only one input, so assume we can follow it backwards.
const link = parent.getInputLink(0);
if (link) {
return [parent.graph, link]
}
}
console.warn("[graphToPrompt] Frontend node does not support getUpstreamLink", parent.type)
return [null, null];
}
/*
* Traverses the graph upstream from outputs towards inputs across
* a sequence of nodes dependent on a condition.
*
* Returns the node and the output link attached to it that leads to the
* starting node if any.
*/
locateUpstream(fromNode: LGraphNode, inputIndex: SlotIndex, tag: string | null): [LGraphNode | null, LLink | null] {
let parent = fromNode.getInputNode(inputIndex);
if (!parent)
return [null, null];
const seen = {}
let currentLink = fromNode.getInputLink(inputIndex);
const shouldFollowParent = (parent: LGraphNode) => {
return isActiveNode(parent, tag) && !this.isTheTargetNode(parent);
}
// If there are non-target nodes between us and another
// backend node, we have to traverse them first. This
// behavior is dependent on the type of node. Reroute nodes
// will simply follow their single input, while branching
// nodes have conditional logic that determines which link
// to follow backwards.
while (shouldFollowParent(parent)) {
const [nextGraph, nextLink] = this.getUpstreamLink(parent, currentLink);
if (nextLink == null) {
console.warn("[graphToPrompt] No upstream link found in frontend node", parent)
break;
}
if (nextLink && !seen[nextLink.id]) {
seen[nextLink.id] = true
const nextParent = nextGraph.getNodeById(nextLink.origin_id);
if (!isActiveNode(parent, tag)) {
parent = null;
}
else {
console.debug("[graphToPrompt] Traverse upstream link", parent.id, nextParent?.id, (nextParent as any)?.isBackendNode)
currentLink = nextLink;
parent = nextParent;
}
} else {
parent = null;
}
}
if (!isActiveNode(parent, tag) || !this.isTheTargetNode(parent) || currentLink == null)
return [null, null];
return [parent, currentLink]
}
}
export default class ComfyPromptSerializer {
serializeInputValues(node: ComfyBackendNode): Record<string, SerializedPromptInput> {
// Store input values passed by frontend-only nodes
if (!node.inputs) {
return {}
}
const inputs = {}
for (let i = 0; i < node.inputs.length; i++) {
const inp = node.inputs[i];
const inputLink = node.getInputLink(i)
const inputNode = node.getInputNode(i)
// We don't check tags for non-backend nodes.
// Just check for node inactivity (so you can toggle groups of
// tagged frontend nodes on/off)
if (inputNode && inputNode.mode === NodeMode.NEVER) {
console.debug("Skipping inactive node", inputNode)
continue;
}
if (!inputLink || !inputNode) {
if ("config" in inp) {
const defaultValue = (inp as IComfyInputSlot).config?.defaultValue
if (defaultValue !== null && defaultValue !== undefined)
inputs[inp.name] = defaultValue
}
continue;
}
let serialize = true;
if ("config" in inp)
serialize = (inp as IComfyInputSlot).serialize
let isBackendNode = node.isBackendNode;
let isInputBackendNode = false;
if ("isBackendNode" in inputNode)
isInputBackendNode = (inputNode as ComfyGraphNode).isBackendNode;
// The reasoning behind this check:
// We only want to serialize inputs to nodes with backend equivalents.
// And in ComfyBox, the backend nodes in litegraph *never* have widgets, instead they're all inputs.
// All values are passed by separate frontend-only nodes,
// either UI-bound or something like ConstantInteger.
// So we know that any value passed into a backend node *must* come from
// a frontend node.
// The rest (links between backend nodes) will be serialized after this bit runs.
if (serialize && isBackendNode && !isInputBackendNode) {
inputs[inp.name] = inputLink.data
}
}
return inputs
}
serializeBackendLinks(node: ComfyBackendNode, tag: string | null): Record<string, SerializedPromptInput> {
const inputs = {}
// Find a backend node upstream following before any number of frontend nodes
const test = (node: LGraphNode) => (node as any).isBackendNode
const nodeLocator = new UpstreamNodeLocator(test)
// Store links between backend-only and hybrid nodes
for (let i = 0; i < node.inputs.length; i++) {
const [backendNode, linkLeadingTo] = nodeLocator.locateUpstream(node, i, tag)
if (backendNode) {
console.debug("[graphToPrompt] final link", backendNode.id, "-->", node.id)
const input = node.inputs[i]
if (!(input.name in inputs))
inputs[input.name] = [String(linkLeadingTo.origin_id), linkLeadingTo.origin_slot];
}
else {
console.warn("[graphToPrompt] Didn't find upstream link!", node.id, node.type, node.title)
}
}
return inputs
}
serialize(graph: ComfyGraph, tag: string | null = null): SerializedPrompt {
// Run frontend-only logic
graph.runStep(1)
const workflow = graph.serialize();
const output: SerializedPromptInputsAll = {};
// Process nodes in order of execution
for (const node of graph.computeExecutionOrderRecursive<ComfyGraphNode>(false, null)) {
const n = workflow.nodes.find((n) => n.id === node.id);
if (!isActiveBackendNode(node, tag)) {
continue;
}
const inputs = this.serializeInputValues(node);
const links = this.serializeBackendLinks(node, tag);
output[String(node.id)] = {
inputs: { ...inputs, ...links },
class_type: node.comfyClass,
};
}
// Remove inputs connected to removed nodes
for (const nodeId in output) {
for (const inputName in output[nodeId].inputs) {
if (Array.isArray(output[nodeId].inputs[inputName])
&& output[nodeId].inputs[inputName].length === 2
&& !output[output[nodeId].inputs[inputName][0]]) {
console.debug("Prune removed node link", nodeId, inputName, output[nodeId].inputs[inputName])
delete output[nodeId].inputs[inputName];
}
}
}
// console.debug({ workflow, output })
// console.debug(promptToGraphVis({ workflow, output }))
return { workflow, output };
}
}

View File

@@ -4,10 +4,11 @@
import { LGraphNode } from "@litegraph-ts/core"
import layoutState, { type IDragItem, type WidgetLayout, ALL_ATTRIBUTES, type AttributesSpec } from "$lib/stores/layoutState"
import uiState from "$lib/stores/uiState"
import selectionState from "$lib/stores/selectionState"
import { get, type Writable, writable } from "svelte/store"
import type { ComfyWidgetNode } from "$lib/nodes";
import ComfyNumberProperty from "./ComfyNumberProperty.svelte";
import ComfyComboProperty from "./ComfyComboProperty.svelte";
import type { ComfyWidgetNode } from "$lib/nodes/widgets";
let target: IDragItem | null = null;
let node: LGraphNode | null = null;
@@ -17,20 +18,21 @@
$: refreshPropsPanel = $layoutState.refreshPropsPanel;
$: if ($layoutState.currentSelection.length > 0) {
const targetId = $layoutState.currentSelection.slice(-1)[0]
target = $layoutState.allItems[targetId].dragItem
attrsChanged = target.attrsChanged;
if (target.type === "widget") {
node = (target as WidgetLayout).node
}
else {
node = null;
$: if ($selectionState.currentSelection.length > 0) {
node = null;
const targetId = $selectionState.currentSelection.slice(-1)[0]
const entry = $layoutState.allItems[targetId]
if (entry != null) {
target = entry.dragItem
attrsChanged = target.attrsChanged;
if (target.type === "widget") {
node = (target as WidgetLayout).node
}
}
}
else if ($layoutState.currentSelectionNodes.length > 0) {
else if ($selectionState.currentSelectionNodes.length > 0) {
target = null;
node = $layoutState.currentSelectionNodes[0]
node = $selectionState.currentSelectionNodes[0]
attrsChanged = null;
}
else {
@@ -130,7 +132,7 @@
value = spec.defaultValue
else if (spec.serialize)
value = spec.serialize(value)
console.debug("[ComfyProperties] getAttribute", spec.name, value, target, spec)
// console.debug("[ComfyProperties] getAttribute", spec.name, value, target, spec)
return value
}
@@ -140,13 +142,17 @@
const name = spec.name
console.debug("[ComfyProperties] updateAttribute", spec, value, name, node)
// console.debug("[ComfyProperties] updateAttribute", spec, value, name, node)
if (spec.deserialize)
value = spec.deserialize(value)
const prevValue = target.attrs[name]
target.attrs[name] = value
target.attrsChanged.set(get(target.attrsChanged) + 1)
if (spec.onChanged)
spec.onChanged(target, value, prevValue)
if (node && "propsChanged" in node) {
const comfyNode = node as ComfyWidgetNode
comfyNode.propsChanged.set(get(comfyNode.propsChanged) + 1)
@@ -164,7 +170,7 @@
value = spec.defaultValue
else if (spec.serialize)
value = spec.serialize(value)
console.debug("[ComfyProperties] getProperty", spec, value, node)
// console.debug("[ComfyProperties] getProperty", spec, value, node)
return value
}
@@ -173,13 +179,17 @@
return
const name = spec.name
console.warn("[ComfyProperties] updateProperty", name, value)
// console.warn("[ComfyProperties] updateProperty", name, value)
if (spec.deserialize)
value = spec.deserialize(value)
const prevValue = node.properties[name]
node.properties[name] = value;
if (spec.onChanged)
spec.onChanged(node, value, prevValue)
if ("propsChanged" in node) {
const comfyNode = node as ComfyWidgetNode
comfyNode.notifyPropsChanged();
@@ -195,7 +205,7 @@
value = spec.defaultValue
else if (spec.serialize)
value = spec.serialize(value)
console.debug("[ComfyProperties] getVar", spec, value, node)
// console.debug("[ComfyProperties] getVar", spec, value, node)
return value
}
@@ -205,12 +215,16 @@
const name = spec.name
console.debug("[ComfyProperties] updateVar", spec, value, name, node)
// console.debug("[ComfyProperties] updateVar", spec, value, name, node)
if (spec.deserialize)
value = spec.deserialize(value)
const prevValue = node[name]
node[name] = value;
if (spec.onChanged)
spec.onChanged(node, value, prevValue)
if ("propsChanged" in node) {
const comfyNode = node as ComfyWidgetNode
comfyNode.propsChanged.set(get(comfyNode.propsChanged) + 1)
@@ -227,7 +241,7 @@
value = spec.defaultValue
else if (spec.serialize)
value = spec.serialize(value)
console.debug("[ComfyProperties] getWorkflowAttribute", spec.name, value, spec, $layoutState.attrs[spec.name])
// console.debug("[ComfyProperties] getWorkflowAttribute", spec.name, value, spec, $layoutState.attrs[spec.name])
return value
}
@@ -236,11 +250,15 @@
return;
const name = spec.name
console.warn("updateWorkflowAttribute", name, value)
// console.warn("[ComfyProperties] updateWorkflowAttribute", name, value)
const prevValue = value
$layoutState.attrs[name] = value
$layoutState = $layoutState
if (spec.onChanged)
spec.onChanged($layoutState, value, prevValue)
if (spec.refreshPanelOnChange)
doRefreshPanel()
}

View File

@@ -65,9 +65,11 @@
dateStr = formatDate(date);
}
const subgraphs: string[] | null = entry.extraData?.extra_pnginfo?.comfyBoxSubgraphs;
let message = "Prompt";
if (entry.extraData.subgraphs)
message = `Prompt: ${entry.extraData.subgraphs.join(', ')}`
if (subgraphs?.length > 0)
message = `Prompt: ${subgraphs.join(', ')}`
let submessage = `Nodes: ${Object.keys(entry.prompt).length}`
if (Object.keys(entry.outputs).length > 0) {
@@ -156,12 +158,14 @@
}
let showModal = false;
let expandAll = false;
let selectedPrompt = null;
let selectedImages = [];
function showPrompt(entry: QueueUIEntry, e: MouseEvent) {
selectedPrompt = entry.entry.prompt;
selectedImages = entry.images;
showModal = true;
expandAll = false
}
$: if(!showModal)
@@ -180,8 +184,16 @@
<h1 style="padding-bottom: 1rem;">Prompt Details</h1>
</div>
{#if selectedPrompt}
<PromptDisplay prompt={selectedPrompt} images={selectedImages} />
<PromptDisplay prompt={selectedPrompt} images={selectedImages} {expandAll} />
{/if}
<div slot="buttons" let:closeDialog>
<Button variant="secondary" on:click={closeDialog}>
Close
</Button>
<Button variant="secondary" on:click={() => (expandAll = !expandAll)}>
Expand All
</Button>
</div>
</Modal>
<div class="queue">

View File

@@ -8,6 +8,7 @@
import WidgetContainer from "./WidgetContainer.svelte";
import layoutState, { type ContainerLayout, type DragItem, type IDragItem } from "$lib/stores/layoutState";
import uiState from "$lib/stores/uiState";
import selectionState from "$lib/stores/selectionState";
import Menu from './menu/Menu.svelte';
import MenuOption from './menu/MenuOption.svelte';
@@ -29,19 +30,72 @@
// TODO
}
function moveTo(delta: number | ((cur: number, total: number) => number)) {
const dragItemID = $selectionState.currentSelection[0];
const entry = $layoutState.allItems[dragItemID];
if (!entry) {
return
}
const dragItem = entry.dragItem;
const containing = entry.parent
if (containing == null || containing.type !== "container") {
return
}
const containingEntry = $layoutState.allItems[containing.id];
const oldIndex = containingEntry.children.findIndex(c => c.id === dragItem.id)
if (oldIndex === -1) {
return;
}
let newIndex: number;
if (typeof delta === "number")
newIndex = oldIndex + delta;
else
newIndex = delta(oldIndex, containingEntry.children.length);
layoutState.moveItem(dragItem, containing as ContainerLayout, newIndex)
$layoutState = $layoutState
}
function moveUp() {
moveTo(-1)
}
function moveDown() {
moveTo(1)
}
function sendToTop() {
moveTo(() => 0)
}
function sendToBottom() {
moveTo((cur: number, total: number) => total - 1)
}
function groupWidgets(horizontal: boolean) {
const items = layoutState.getCurrentSelection()
$layoutState.currentSelection = []
const items = $selectionState.currentSelection
$selectionState.currentSelection = []
layoutState.groupItems(items, { direction: horizontal ? "horizontal" : "vertical" })
}
let canUngroup = false;
let isDeleteGroup = false;
$: canUngroup = $layoutState.currentSelection.length === 1
&& layoutState.getCurrentSelection()[0].type === "container"
$: {
canUngroup = false;
if ($selectionState.currentSelection.length === 1) {
const entry = $layoutState.allItems[$selectionState.currentSelection[0]]
if (entry != null) {
const item = entry.dragItem;
canUngroup = item.type === "container"
}
}
}
$: if (canUngroup) {
const dragItem = layoutState.getCurrentSelection()[0];
const entry = $layoutState.allItems[dragItem.id];
const dragItemID = $selectionState.currentSelection[0];
const entry = $layoutState.allItems[dragItemID];
isDeleteGroup = entry.children.length === 0
}
else {
@@ -49,11 +103,19 @@
}
function ungroup() {
const item = layoutState.getCurrentSelection()[0]
if (!item || item.type !== "container")
const itemID = $selectionState.currentSelection[0]
if (itemID == null)
return;
$layoutState.currentSelection = []
const entry = $layoutState.allItems[$selectionState.currentSelection[0]]
if (entry == null)
return
const item = entry.dragItem;
if(item.type !== "container")
return
$selectionState.currentSelection = []
layoutState.ungroup(item as ContainerLayout)
}
@@ -94,11 +156,28 @@
{#if showMenu}
<Menu {...menuPos} on:click={closeMenu} on:clickoutside={closeMenu}>
<MenuOption
isDisabled={$layoutState.currentSelection.length === 0}
isDisabled={$selectionState.currentSelection.length !== 1}
on:click={() => moveUp()}
text="Move Up" />
<MenuOption
isDisabled={$selectionState.currentSelection.length !== 1}
on:click={() => moveDown()}
text="Move Down" />
<MenuOption
isDisabled={$selectionState.currentSelection.length !== 1}
on:click={() => sendToTop()}
text="Send to Top" />
<MenuOption
isDisabled={$selectionState.currentSelection.length !== 1}
on:click={() => sendToBottom()}
text="Send to Bottom" />
<MenuDivider/>
<MenuOption
isDisabled={$selectionState.currentSelection.length === 0}
on:click={() => groupWidgets(false)}
text="Group" />
<MenuOption
isDisabled={$layoutState.currentSelection.length === 0}
isDisabled={$selectionState.currentSelection.length === 0}
on:click={() => groupWidgets(true)}
text="Group Horizontally" />
<MenuOption

View File

@@ -1,28 +1,21 @@
<script lang="ts">
import { Block, BlockTitle } from "@gradio/atoms";
import uiState from "$lib/stores/uiState";
import WidgetContainer from "./WidgetContainer.svelte"
import selectionState from "$lib/stores/selectionState";
import BlockContainer from "./BlockContainer.svelte"
import AccordionContainer from "./AccordionContainer.svelte"
import TabsContainer from "./TabsContainer.svelte"
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import {fade} from 'svelte/transition';
// notice - fade in works fine but don't add svelte's fade-out (known issue)
import {cubicIn} from 'svelte/easing';
import { flip } from 'svelte/animate';
import layoutState, { type ContainerLayout, type WidgetLayout, type IDragItem } from "$lib/stores/layoutState";
import { startDrag, stopDrag } from "$lib/utils"
import type { Writable } from "svelte/store";
import { isHidden } from "$lib/widgets/utils";
import { type ContainerLayout } from "$lib/stores/layoutState";
import type { Writable } from "svelte/store";
import { isHidden } from "$lib/widgets/utils";
export let container: ContainerLayout | null = null;
export let zIndex: number = 0;
export let classes: string[] = [];
export let showHandles: boolean = false;
export let isMobile: boolean = false
let attrsChanged: Writable<boolean> | null = null;
let attrsChanged: Writable<number> | null = null;
$: if (container) {
attrsChanged = container.attrsChanged
@@ -33,8 +26,8 @@
</script>
{#if container}
{@const edit = $uiState.uiUnlocked && $uiState.uiEditMode === "widgets" && zIndex > 1}
{@const dragDisabled = zIndex === 0 || $layoutState.currentSelection.length > 2 || !$uiState.uiUnlocked}
{@const edit = $uiState.uiUnlocked && $uiState.uiEditMode === "widgets"}
{@const dragDisabled = zIndex === 0 || $selectionState.currentSelection.length > 2 || !$uiState.uiUnlocked}
{#key $attrsChanged}
{#if edit || !isHidden(container)}
{#if container.attrs.variant === "tabs"}

View File

@@ -42,11 +42,6 @@
imgElem.src = convertComfyOutputToComfyURL(value[0])
}
$: if (!(_value && _value.length > 0 && imgElem)) {
imgWidth = 1
imgHeight = 1
}
function onChange() {
dispatch("change", value)
}
@@ -119,6 +114,8 @@
uploaded = false;
pending_upload = true;
imgWidth = 0;
imgHeight = 0;
old_value = _value;
if (_value == null)
@@ -177,9 +174,13 @@
uploaded = true;
}
$: console.warn(imgWidth, imgHeight, "IMGSIZE!!")
function handle_clear(_e: CustomEvent<null>) {
_value = null;
value = [];
imgWidth = 0;
imgHeight = 0;
dispatch("change", value)
dispatch("clear")
}

View File

@@ -25,6 +25,7 @@
}
function doClose() {
showModal = false;
dialog.close();
dispatch("close")
}
@@ -41,7 +42,7 @@
<slot name="header" />
<slot />
<div class="button-row">
<slot name="buttons">
<slot name="buttons" {closeDialog}>
<!-- svelte-ignore a11y-autofocus -->
<Button variant="secondary" on:click={doClose}>Close</Button>
</slot>

View File

@@ -6,16 +6,20 @@
import { JSON as JSONIcon, Copy, Check } from "@gradio/icons";
import Accordion from "$lib/components/gradio/app/Accordion.svelte";
import Gallery from "$lib/components/gradio/gallery/Gallery.svelte";
import type { Styles } from "@gradio/utils";
import { ImageViewer } from "$lib/ImageViewer";
import type { Styles } from "@gradio/utils";
const splitLength = 50;
export let prompt: SerializedPromptInputsAll;
export let images: string[] = [];
export let isMobile: boolean = false;
export let expandAll: boolean = false;
let galleryStyle: Styles = {
grid_cols: [2],
object_fit: "cover",
height: "var(--size-96)"
}
function isInputLink(input: SerializedPromptInput): boolean {
@@ -59,6 +63,11 @@
copyFeedback(nodeID, inputName);
}
}
function onGalleryImageClicked(e: CustomEvent<HTMLImageElement>) {
// TODO dialog renders over it
// ImageViewer.instance.showLightbox(e.detail)
}
</script>
<div class="prompt-display">
@@ -70,7 +79,7 @@
{#if filtered.length > 0}
<div class="accordion">
<Block padding={true}>
<Accordion label="Node {i+1}: {classType}" open={false}>
<Accordion label="Node {i+1}: {classType}" open={expandAll}>
{#each filtered as [inputName, input]}
<Block>
<button class="copy-button" on:click={() => handleCopy(nodeID, inputName, input)}>
@@ -121,6 +130,7 @@
style={galleryStyle}
root={""}
root_url={""}
on:clicked={onGalleryImageClicked}
/>
</Block>
</div>

View File

@@ -2,6 +2,7 @@
import { Block, BlockTitle } from "@gradio/atoms";
import { Tabs, TabItem } from "@gradio/tabs";
import uiState from "$lib/stores/uiState";
import selectionState from "$lib/stores/selectionState";
import WidgetContainer from "./WidgetContainer.svelte"
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
@@ -67,10 +68,11 @@
}
</script>
{#if container && children}
{#if container && Array.isArray(children)}
{@const selected = $uiState.uiUnlocked && $selectionState.currentSelection.includes(container.id)}
<div class="container {container.attrs.direction} {container.attrs.classes} {classes.join(' ')} z-index{zIndex}"
class:hide-block={container.attrs.containerVariant === "hidden"}
class:selected={$uiState.uiUnlocked && $layoutState.currentSelection.includes(container.id)}
class:selected
class:root-container={zIndex === 0}
class:is-executing={container.isNodeExecuting}
class:edit={edit}>
@@ -133,6 +135,10 @@
.container {
display: flex;
&.selected {
background: var(--comfy-container-selected-background-fill) !important;
}
> :global(*) {
border-radius: 0;
}

View File

@@ -3,11 +3,11 @@
import uiState from "$lib/stores/uiState";
import layoutState, { type ContainerLayout, type WidgetLayout, type IDragItem } from "$lib/stores/layoutState";
import selectionState from "$lib/stores/selectionState";
import { startDrag, stopDrag } from "$lib/utils"
import Container from "./Container.svelte"
import { type Writable } from "svelte/store"
import type { ComfyWidgetNode } from "$lib/nodes";
import { NodeMode } from "@litegraph-ts/core";
import type { ComfyWidgetNode } from "$lib/nodes/widgets";
import { isHidden } from "$lib/widgets/utils";
export let dragItem: IDragItem | null = null;
@@ -65,23 +65,26 @@
<Container {container} {classes} {zIndex} {showHandles} {isMobile} />
{/key}
{:else if widget && widget.node}
{@const edit = $uiState.uiUnlocked && $uiState.uiEditMode === "widgets" && zIndex > 1}
{@const edit = $uiState.uiUnlocked && $uiState.uiEditMode === "widgets"}
{@const hidden = isHidden(widget)}
{@const hovered = $uiState.uiUnlocked && $selectionState.currentHovered.has(widget.id)}
{@const selected = $uiState.uiUnlocked && $selectionState.currentSelection.includes(widget.id)}
{#key $attrsChanged}
{#key $propsChanged}
<div class="widget {widget.attrs.classes} {getWidgetClass()}"
class:edit={edit}
class:selected={$uiState.uiUnlocked && $layoutState.currentSelection.includes(widget.id)}
class:is-executing={$queueState.runningNodeID && $queueState.runningNodeId == widget.node.id}
class:hidden={hidden}
>
class:hovered
class:selected
class:is-executing={$queueState.runningNodeID && $queueState.runningNodeID == widget.node.id}
class:hidden={hidden}
>
<svelte:component this={widget.node.svelteComponentType} {widget} {isMobile} />
</div>
{#if hidden && edit}
<div class="handle handle-hidden" class:hidden={!edit} />
{/if}
{#if showHandles}
<div class="handle handle-widget" data-drag-item-id={widget.id} on:mousedown={startDrag} on:touchstart={startDrag} on:mouseup={stopDrag} on:touchend={stopDrag}/>
{#if showHandles || hovered}
<div class="handle handle-widget" class:hovered data-drag-item-id={widget.id} on:mousedown={startDrag} on:touchstart={startDrag} on:mouseup={stopDrag} on:touchend={stopDrag}/>
{/if}
{/key}
{/key}
@@ -92,12 +95,9 @@
height: 100%;
&.selected {
background: var(--color-yellow-200);
background: var(--comfy-widget-selected-background-fill);
}
}
.container.selected {
background: var(--color-yellow-400);
}
.is-executing {
border: 3px dashed var(--color-green-600) !important;
@@ -123,8 +123,10 @@
background-color: #40404080;
}
.handle-widget:hover {
background-color: #add8e680;
.handle-widget {
&:hover, &.hovered {
background-color: #add8e680;
}
}
.node-type {
@@ -134,7 +136,5 @@
.edit {
border: 2px dashed var(--color-blue-400);
margin: 0.2em;
padding: 0.2em;
}
</style>

View File

@@ -2,7 +2,7 @@
import type { Styles } from "@gradio/utils";
export let style: Styles = {};
export let elem_id: string;
export let elem_id: string | null;
export let elem_classes: Array<string> = [];
export let visible: boolean = true;
export let variant: "default" | "panel" | "compact" = "default";

View File

@@ -21,19 +21,20 @@
object_fit: "cover",
height: "auto"
};
export let imageWidth: number = 1;
export let imageHeight: number = 1;
export let imageWidth: number = 0;
export let imageHeight: number = 0;
const dispatch = createEventDispatcher<{
select: SelectData;
clicked: HTMLImageElement
}>();
// tracks whether the value of the gallery was reset
let was_reset: boolean = true;
$: if (selected_image == null || was_reset) {
imageWidth = 1;
imageHeight = 1;
imageWidth = 0;
imageHeight = 0;
}
$: was_reset = value == null || value.length == 0 ? true : was_reset;
@@ -142,6 +143,14 @@
let height = 0;
let window_height = 0;
let imgElem = null;
function onClick() {
// selected_image = next
if (imgElem)
dispatch("clicked", imgElem)
}
</script>
<svelte:window bind:innerHeight={window_height} />
@@ -166,7 +175,8 @@
<ModifyUpload on:clear={() => (selected_image = null)} />
<img
on:click={() => (selected_image = next)}
on:click={onClick}
bind:this={imgElem}
src={_value[selected_image][0].data}
alt={_value[selected_image][1] || ""}
title={_value[selected_image][1] || null}

View File

@@ -11,8 +11,8 @@
export let label: string | undefined = undefined;
export let show_label: boolean;
export let selectable: boolean = false;
export let imageWidth: number = 1;
export let imageHeight: number = 1;
export let imageWidth: number = 0;
export let imageHeight: number = 0;
let imageElem: HTMLImageElement | null = null;
const dispatch = createEventDispatcher<{
@@ -23,8 +23,8 @@
$: value && dispatch("change", value);
$: if (value == null || !imageElem) {
imageWidth = 1;
imageHeight = 1;
imageWidth = 0;
imageHeight = 0;
}
const handle_click = (evt: MouseEvent) => {

32
src/lib/init.ts Normal file
View File

@@ -0,0 +1,32 @@
import ComfyGraph from '$lib/ComfyGraph';
import { LGraphCanvas, LiteGraph, Subgraph } from '@litegraph-ts/core';
import layoutState from './stores/layoutState';
import { get } from 'svelte/store';
export function configureLitegraph(isMobile: boolean = false) {
LiteGraph.catch_exceptions = false;
// Must be enabled, otherwise subgraphs won't work (because of non-unique node/link IDs)
LiteGraph.use_uuids = true;
LiteGraph.search_filter_enabled = true;
LiteGraph.release_link_on_empty_shows_menu = true;
LiteGraph.alt_drag_do_clone_nodes = true;
LiteGraph.middle_click_slot_add_default_node = true;
LiteGraph.dialog_close_on_mouse_leave = false;
LiteGraph.search_hide_on_mouse_leave = false;
LiteGraph.graph_inputs_outputs_use_combo_widget = true;
LiteGraph.search_box_refresh_interval_ms = 150;
LiteGraph.CANVAS_GRID_SIZE = 32;
if (isMobile) {
LiteGraph.pointerevents_method = "pointer";
}
Subgraph.default_lgraph_factory = () => new ComfyGraph;
(window as any).LiteGraph = LiteGraph;
(window as any).LGraphCanvas = LGraphCanvas;
(window as any).layoutState = get(layoutState)
}

View File

@@ -5,10 +5,10 @@ import queueState from "$lib/stores/queueState";
import { BuiltInSlotType, LiteGraph, NodeMode, type ITextWidget, type IToggleWidget, type SerializedLGraphNode, type SlotLayout, type PropertyLayout } from "@litegraph-ts/core";
import { get } from "svelte/store";
import ComfyGraphNode, { type ComfyGraphNodeProperties } from "./ComfyGraphNode";
import type { ComfyWidgetNode, ComfyExecutionResult, ComfyImageLocation } from "./ComfyWidgetNodes";
import type { ComfyWidgetNode } from "$lib/nodes/widgets";
import type { NotifyOptions } from "$lib/notify";
import type { FileData as GradioFileData } from "@gradio/upload";
import { convertComfyOutputToGradio, type ComfyUploadImageAPIResponse } from "$lib/utils";
import { type ComfyExecutionResult, type ComfyImageLocation, convertComfyOutputToGradio, type ComfyUploadImageAPIResponse, parseWhateverIntoComfyImageLocations } from "$lib/utils";
export class ComfyQueueEvents extends ComfyGraphNode {
static slotLayout: SlotLayout = {
@@ -59,7 +59,7 @@ LiteGraph.registerNodeType({
class: ComfyQueueEvents,
title: "Comfy.QueueEvents",
desc: "Triggers a 'bang' event when a prompt is queued.",
type: "actions/queue_events"
type: "events/queue_events"
})
export interface ComfyStoreImagesActionProperties extends ComfyGraphNodeProperties {
@@ -177,8 +177,8 @@ export class ComfySwapAction extends ComfyGraphNode {
override onAction(action: any, param: any) {
const a = this.getInputData(0)
const b = this.getInputData(1)
this.triggerSlot(0, a)
this.triggerSlot(1, b)
this.triggerSlot(0, b)
this.triggerSlot(1, a)
};
}
@@ -469,7 +469,7 @@ export class ComfySetNodeModeAdvancedAction extends ComfyGraphNode {
}
private getModeChanges(action: TagAction, enable: boolean, nodeChanges: Record<string, NodeMode>, widgetChanges: Record<DragItemID, boolean>) {
for (const node of this.graph._nodes) {
for (const node of this.graph.iterateNodesInOrderRecursive()) {
if ("tags" in node.properties) {
const comfyNode = node as ComfyGraphNode;
const hasTag = comfyNode.properties.tags.indexOf(action.tag) != -1;
@@ -482,9 +482,6 @@ export class ComfySetNodeModeAdvancedAction extends ComfyGraphNode {
newMode = NodeMode.NEVER;
}
nodeChanges[node.id] = newMode
node.changeMode(newMode);
if ("notifyPropsChanged" in node)
(node as ComfyWidgetNode).notifyPropsChanged();
}
}
}
@@ -530,7 +527,7 @@ export class ComfySetNodeModeAdvancedAction extends ComfyGraphNode {
}
for (const [nodeId, newMode] of Object.entries(nodeChanges)) {
this.graph.getNodeById(nodeId).changeMode(newMode);
this.graph.getNodeByIdRecursive(nodeId).changeMode(newMode);
}
const layout = get(layoutState);
@@ -601,27 +598,7 @@ export class ComfySetPromptThumbnailsAction extends ComfyGraphNode {
override getPromptThumbnails(): ComfyImageLocation[] | null {
const data = this.getInputData(0)
const folderType = this.properties.folderType || "input";
const convertString = (s: string): ComfyImageLocation => {
return { filename: data, subfolder: "", type: folderType }
}
if (typeof data === "string") {
return [convertString(data)]
}
else if (data != null && typeof data === "object") {
if ("filename" in data && "type" in data)
return [data as ComfyImageLocation];
}
else if (Array.isArray(data) && data.length > 0) {
if (typeof data[0] === "string")
return data.map(convertString)
else if (typeof data[0] === "object" && "filename" in data[0] && "type" in data[0])
return data as ComfyImageLocation[]
}
return null;
return parseWhateverIntoComfyImageLocations(data);
}
}

View File

@@ -1,10 +1,12 @@
import LGraphCanvas from "@litegraph-ts/core/src/LGraphCanvas";
import ComfyGraphNode from "./ComfyGraphNode";
import ComfyWidgets from "$lib/widgets"
import type { ComfyWidgetNode, ComfyExecutionResult } from "./ComfyWidgetNodes";
import type { ComfyWidgetNode } from "$lib/nodes/widgets";
import { BuiltInSlotShape, BuiltInSlotType, type SerializedLGraphNode } from "@litegraph-ts/core";
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import type { ComfyInputConfig } from "$lib/IComfyInputSlot";
import { iterateNodeDefOutputs, type ComfyNodeDef, iterateNodeDefInputs } from "$lib/ComfyNodeDef";
import type { ComfyExecutionResult } from "$lib/utils";
/*
* Base class for any node with configuration sent by the backend.
@@ -30,29 +32,26 @@ export class ComfyBackendNode extends ComfyGraphNode {
// It just returns a hash like { "ui": { "images": results } } internally.
// So this will need to be hardcoded for now.
if (["PreviewImage", "SaveImage"].indexOf(comfyClass) !== -1) {
this.addOutput("onExecuted", BuiltInSlotType.EVENT, { color_off: "rebeccapurple", color_on: "rebeccapurple" });
this.addOutput("OUTPUT", BuiltInSlotType.EVENT, { color_off: "rebeccapurple", color_on: "rebeccapurple" });
}
}
// comfy class -> input name -> input config
private static defaultInputConfigs: Record<string, Record<string, ComfyInputConfig>> = {}
private setup(nodeData: any) {
var inputs = nodeData["input"]["required"];
if (nodeData["input"]["optional"] != undefined) {
inputs = Object.assign({}, nodeData["input"]["required"], nodeData["input"]["optional"])
}
private setup(nodeDef: ComfyNodeDef) {
ComfyBackendNode.defaultInputConfigs[this.type] = {}
for (const inputName in inputs) {
for (const [inputName, inputData] of iterateNodeDefInputs(nodeDef)) {
const config: Partial<IComfyInputSlot> = {};
const inputData = inputs[inputName];
const type = inputData[0];
const [type, opts] = inputData;
if (inputData[1]?.forceInput) {
this.addInput(inputName, type);
if (opts?.forceInput) {
if (Array.isArray(type)) {
throw new Error(`Can't have forceInput set to true for an enum type! ${type}`)
}
this.addInput(inputName, type as string);
} else {
if (Array.isArray(type)) {
// Enums
@@ -73,11 +72,9 @@ export class ComfyBackendNode extends ComfyGraphNode {
ComfyBackendNode.defaultInputConfigs[this.type][inputName] = (config as IComfyInputSlot).config
}
for (const o in nodeData["output"]) {
const output = nodeData["output"][o];
const outputName = nodeData["output_name"][o] || output;
const outputShape = nodeData["output_is_list"][o] ? BuiltInSlotShape.GRID_SHAPE : BuiltInSlotShape.CIRCLE_SHAPE;
this.addOutput(outputName, output, { shape: outputShape });
for (const output of iterateNodeDefOutputs(nodeDef)) {
const outputShape = output.is_list ? BuiltInSlotShape.GRID_SHAPE : BuiltInSlotShape.CIRCLE_SHAPE;
this.addOutput(output.name, output.type, { shape: outputShape });
}
this.serialize_widgets = false;

View File

@@ -1,9 +1,9 @@
import type { ComfyInputConfig } from "$lib/IComfyInputSlot";
import type { SerializedPrompt } from "$lib/components/ComfyApp";
import type ComfyWidget from "$lib/components/widgets/ComfyWidget";
import { LGraph, LGraphNode, LLink, LiteGraph, NodeMode, type INodeInputSlot, type SerializedLGraphNode, type Vector2, type INodeOutputSlot, LConnectionKind, type SlotType, LGraphCanvas, getStaticPropertyOnInstance, type PropertyLayout, type SlotLayout } from "@litegraph-ts/core";
import type { SvelteComponentDev } from "svelte/internal";
import type { ComfyWidgetNode, ComfyExecutionResult, ComfyImageLocation } from "./ComfyWidgetNodes";
import type { ComfyWidgetNode } from "$lib/nodes/widgets";
import type { ComfyExecutionResult, ComfyImageLocation } from "$lib/utils"
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import uiState from "$lib/stores/uiState";
import { get } from "svelte/store";

View File

@@ -1,33 +0,0 @@
import { LiteGraph, type SlotLayout } from "@litegraph-ts/core";
import ComfyGraphNode from "./ComfyGraphNode";
import { comfyFileToAnnotatedFilepath, isComfyBoxImageMetadata, parseWhateverIntoImageMetadata } from "$lib/utils";
export default class ComfyImageToFilepathNode extends ComfyGraphNode {
static slotLayout: SlotLayout = {
inputs: [
{ name: "image", type: "COMFYBOX_IMAGES,COMFYBOX_IMAGE" },
],
outputs: [
{ name: "filepath", type: "string" },
]
}
override onExecute() {
const data = this.getInputData(0)
const meta = parseWhateverIntoImageMetadata(data);
if (meta == null || meta.length === 0) {
this.setOutputData(0, null)
return;
}
const path = comfyFileToAnnotatedFilepath(meta[0].comfyUIFile);
this.setOutputData(0, path);
}
}
LiteGraph.registerNodeType({
class: ComfyImageToFilepathNode,
title: "Comfy.ImageToFilepath",
desc: "Converts ComfyBox image metadata to an annotated filepath like \"image.png[output]\" for use with ComfyUI.",
type: "image/file_to_filepath"
})

View File

@@ -1,35 +1,98 @@
import { LiteGraph, type SlotLayout } from "@litegraph-ts/core";
import { LiteGraph, type ITextWidget, type SlotLayout, type INumberWidget } from "@litegraph-ts/core";
import ComfyGraphNode from "./ComfyGraphNode";
import { isComfyBoxImageMetadataArray } from "$lib/utils";
import { comfyFileToAnnotatedFilepath, type ComfyBoxImageMetadata } from "$lib/utils";
/*
* TODO: This is just a temporary workaround until litegraph can handle typed
* array arguments.
*/
export default class ComfyPickImageNode extends ComfyGraphNode {
static slotLayout: SlotLayout = {
inputs: [
{ name: "images", type: "COMFYBOX_IMAGES" },
{ name: "images", type: "COMFYBOX_IMAGES,COMFYBOX_IMAGE" },
{ name: "index", type: "number" },
],
outputs: [
{ name: "image", type: "COMFYBOX_IMAGE" },
{ name: "filename", type: "string" },
{ name: "width", type: "number" },
{ name: "height", type: "number" },
]
}
filepathWidget: ITextWidget;
folderWidget: ITextWidget;
widthWidget: INumberWidget;
heightWidget: INumberWidget;
constructor(title?: string) {
super(title)
this.filepathWidget = this.addWidget("text", "File", "")
this.folderWidget = this.addWidget("text", "Folder", "")
this.widthWidget = this.addWidget("number", "Width", 0)
this.heightWidget = this.addWidget("number", "Height", 0)
for (const widget of this.widgets)
widget.disabled = true;
}
_value: ComfyBoxImageMetadata[] | null = null;
_image: ComfyBoxImageMetadata | null = null;
_path: string | null = null;
_index: number = 0;
private setValue(value: ComfyBoxImageMetadata[] | ComfyBoxImageMetadata | null, index: number) {
if (value != null && !Array.isArray(value)) {
value = [value]
index = 0;
}
const changed = this._value != value || this._index != index;
this._value = value as ComfyBoxImageMetadata[];
this._index = index;
if (changed) {
if (value && value[this._index] != null) {
this._image = value[this._index]
this._path = comfyFileToAnnotatedFilepath(this._image.comfyUIFile);
this.filepathWidget.value = this._image.comfyUIFile.filename
this.folderWidget.value = this._image.comfyUIFile.type
}
else {
this._image = null;
this._path = null;
this.filepathWidget.value = "(None)"
this.folderWidget.value = ""
}
console.log("SET", value, this._image, this._path)
}
}
override onExecute() {
const data = this.getInputData(0)
if (data == null || !isComfyBoxImageMetadataArray(data)) {
this.setOutputData(0, null)
return;
}
const index = this.getInputData(1) || 0
this.setValue(data, index);
this.setOutputData(0, data[0]);
if (this._image == null) {
this.setOutputData(0, null)
this.setOutputData(1, null)
this.setOutputData(2, 0)
this.setOutputData(3, 0)
this.widthWidget.value = 0
this.heightWidget.value = 0
}
else {
this.setOutputData(0, this._image);
this.setOutputData(1, this._path);
this.setOutputData(2, this._image.width);
this.setOutputData(3, this._image.height);
// XXX: image size doesn't load until the <img> element is ready on
// the page so this can come after several frames' worth of
// execution
this.widthWidget.value = this._image.width
this.heightWidget.value = this._image.height
}
}
}
LiteGraph.registerNodeType({
class: ComfyPickImageNode,
title: "Comfy.PickImage",
desc: "Picks out the first image from an array of ComfyBox images.",
desc: "Selects an image from an array of ComfyBox images and returns its properties.",
type: "image/pick_image"
})

View File

@@ -2,14 +2,15 @@ import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core"
import ComfyGraphNode, { type ComfyGraphNodeProperties, type DefaultWidgetLayout } from "./ComfyGraphNode";
import { clamp } from "$lib/utils";
import ComboWidget from "$lib/widgets/ComboWidget.svelte";
import { ComfyComboNode } from "./ComfyWidgetNodes";
import { ComfyComboNode } from "./widgets";
export interface ComfyValueControlProperties extends ComfyGraphNodeProperties {
value: any,
action: "fixed" | "increment" | "decrement" | "randomize",
min: number,
max: number,
step: number
step: number,
ignoreStepWhenRandom: boolean
}
const INT_MAX = 1125899906842624;
@@ -18,10 +19,11 @@ export default class ComfyValueControl extends ComfyGraphNode {
override properties: ComfyValueControlProperties = {
tags: [],
value: null,
action: "fixed",
action: "randomize",
min: -INT_MAX,
max: INT_MAX,
step: 1
step: 1,
ignoreStepWhenRandom: false
}
static slotLayout: SlotLayout = {
@@ -61,15 +63,11 @@ export default class ComfyValueControl extends ComfyGraphNode {
}
override onExecute() {
this.setProperty("action", this.getInputData(2) || "fixed")
this.setProperty("min", this.getInputData(3))
this.setProperty("max", this.getInputData(4))
this.setProperty("step", this.getInputData(5) || 1)
if (this._aboutToChange > 0) {
this._aboutToChange -= 1;
if (this._aboutToChange <= 0) {
const value = this._aboutToChangeValue;
console.warn("ABOUTTOCHANGE", value)
this._aboutToChange = 0;
this._aboutToChangeValue = null;
this.triggerSlot(1, value)
@@ -82,8 +80,26 @@ export default class ComfyValueControl extends ComfyGraphNode {
if (typeof v !== "number")
return
let min = this.properties.min
let max = this.properties.max
let action_ = this.getInputData(2);
if (action_ == null)
action_ = "fixed"
let min = this.getInputData(3);
if (min == null)
min = -INT_MAX
let max = this.getInputData(4);
if (max == null)
max = INT_MAX
let step = this.getInputData(5);
if (step == null)
step = 1
this.setProperty("action", action_)
this.setProperty("min", min)
this.setProperty("max", max)
this.setProperty("step", step)
min = this.properties.min
max = this.properties.max
if (min == null) min = -INT_MAX
if (max == null) max = INT_MAX
@@ -103,7 +119,8 @@ export default class ComfyValueControl extends ComfyGraphNode {
v -= this.properties.step;
break;
case "randomize":
v = Math.floor(Math.random() * range) * (this.properties.step) + min;
const step = this.properties.ignoreStepWhenRandom ? 1 : this.properties.step
v = Math.floor(Math.random() * range) * step + min;
default:
break;
}

View File

@@ -1,921 +0,0 @@
import { LiteGraph, type ContextMenuItem, type LGraphNode, type Vector2, LConnectionKind, LLink, LGraphCanvas, type SlotType, TitleMode, type SlotLayout, LGraph, type INodeInputSlot, type ITextWidget, type INodeOutputSlot, type SerializedLGraphNode, BuiltInSlotType, type PropertyLayout, type IComboWidget, NodeMode, type INumberWidget, type UUID } from "@litegraph-ts/core";
import ComfyGraphNode, { type ComfyGraphNodeProperties } from "./ComfyGraphNode";
import type { SvelteComponentDev } from "svelte/internal";
import { Watch } from "@litegraph-ts/nodes-basic";
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import { writable, type Unsubscriber, type Writable, get } from "svelte/store";
import { clamp, convertComfyOutputToGradio, range, type ComfyUploadImageType, isComfyBoxImageMetadata, filenameToComfyBoxMetadata, type ComfyBoxImageMetadata, isComfyExecutionResult, executionResultToImageMetadata, parseWhateverIntoImageMetadata } from "$lib/utils"
import layoutState from "$lib/stores/layoutState";
import type { FileData as GradioFileData } from "@gradio/upload";
import queueState from "$lib/stores/queueState";
import ComboWidget from "$lib/widgets/ComboWidget.svelte";
import RangeWidget from "$lib/widgets/RangeWidget.svelte";
import TextWidget from "$lib/widgets/TextWidget.svelte";
import GalleryWidget from "$lib/widgets/GalleryWidget.svelte";
import ButtonWidget from "$lib/widgets/ButtonWidget.svelte";
import CheckboxWidget from "$lib/widgets/CheckboxWidget.svelte";
import RadioWidget from "$lib/widgets/RadioWidget.svelte";
import ImageUploadWidget from "$lib/widgets/ImageUploadWidget.svelte";
import ImageEditorWidget from "$lib/widgets/ImageEditorWidget.svelte";
import type { NodeID } from "$lib/api";
export type AutoConfigOptions = {
includeProperties?: Set<string> | null,
setDefaultValue?: boolean
setWidgetTitle?: boolean
}
/*
* NOTE: If you want to add a new widget but it has the same input/output type
* as another one of the existing widgets, best to create a new "variant" of
* that widget instead.
*
* - Go to layoutState, look for `ALL_ATTRIBUTES,` insert or find a "variant"
* attribute and set `validNodeTypes` to the type of the litegraph node
* - Add a new entry in the `values` array, like "knob" or "dial" for ComfySliderWidget
* - Add an {#if widget.attrs.variant === <...>} statement in the existing Svelte component
*
* Also, BEWARE of calling setOutputData() and triggerSlot() on the same frame!
* You will have to either implement an internal delay on the event triggering
* or use an Event Delay node to ensure the output slot data can propagate to
* the rest of the graph first (see `delayChangedEvent` for details)
*/
export interface ComfyWidgetProperties extends ComfyGraphNodeProperties {
defaultValue: any
}
/*
* A node that is tied to a UI widget in the frontend. When the frontend's
* widget is changed, the value of the first output in the node is updated
* in the litegraph instance.
*/
export abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
abstract properties: ComfyWidgetProperties;
value: Writable<T>
propsChanged: Writable<number> = writable(0) // dummy to indicate if props changed
unsubscribe: Unsubscriber;
/** Svelte class for the frontend logic */
abstract svelteComponentType: typeof SvelteComponentDev
/** If false, user manually set min/max/step, and should not be autoinherited from connected input */
autoConfig: boolean = true;
copyFromInputLink: boolean = true;
/**
* If true wait until next frame update to trigger the changed event.
* Reason is, if the event is triggered immediately then other stuff that wants to run
* their own onExecute on the output value won't have completed yet.
*/
delayChangedEvent: boolean = true;
private _aboutToChange: number = 0;
private _aboutToChangeValue: any = null;
private _noChangedEvent: boolean = false;
abstract defaultValue: T;
/** Names of properties to add as inputs */
// shownInputProperties: string[] = []
/** Names of properties to add as outputs */
private shownOutputProperties: Record<string, { type: string, index: number }> = {}
outputProperties: { name: string, type: string }[] = []
override isBackendNode = false;
override serialize_widgets = true;
// TODO these are bad, create override methods instead
// input slots
inputIndex: number | null = null;
storeActionName: string | null = "store";
// output slots
outputIndex: number | null = 0;
changedIndex: number | null = 1;
displayWidget: ITextWidget;
override size: Vector2 = [60, 40];
constructor(name: string, value: T) {
const color = LGraphCanvas.node_colors["blue"]
super(name)
this.value = writable(value)
this.color ||= color.color
this.bgColor ||= color.bgColor
this.displayWidget = this.addWidget<ITextWidget>(
"text",
"Value",
""
);
this.displayWidget.disabled = true; // prevent editing
this.unsubscribe = this.value.subscribe(this.onValueUpdated.bind(this))
}
addPropertyAsOutput(propertyName: string, type: string) {
if (this.shownOutputProperties[propertyName])
return;
if (!(propertyName in this.properties)) {
throw `No property named ${propertyName} found!`
}
this.shownOutputProperties[propertyName] = { type, index: this.outputs.length }
this.addOutput(propertyName, type)
}
formatValue(value: any): string {
return Watch.toString(value)
}
override changeMode(modeTo: NodeMode): boolean {
const result = super.changeMode(modeTo);
this.notifyPropsChanged();
return result;
}
private onValueUpdated(value: any) {
// console.debug("[Widget] valueUpdated", this, value)
this.displayWidget.value = this.formatValue(value)
if (this.outputIndex !== null && this.outputs.length >= this.outputIndex) {
this.setOutputData(this.outputIndex, get(this.value))
}
if (this.changedIndex !== null && this.outputs.length >= this.changedIndex && !this._noChangedEvent) {
if (!this.delayChangedEvent)
this.triggerChangeEvent(get(this.value))
else {
// console.debug("[Widget] queueChangeEvent", this, value)
this._aboutToChange = 2; // wait 1.5-2 frames, in case we're already in the middle of executing the graph
this._aboutToChangeValue = get(this.value);
}
}
this._noChangedEvent = false;
}
private triggerChangeEvent(value: any) {
// console.debug("[Widget] trigger changed", this, value)
const changedOutput = this.outputs[this.changedIndex]
if (changedOutput.type === BuiltInSlotType.EVENT)
this.triggerSlot(this.changedIndex, value)
}
parseValue(value: any): T { return value as T };
getValue(): T {
return get(this.value);
}
setValue(value: any, noChangedEvent: boolean = false) {
if (noChangedEvent)
this._noChangedEvent = true;
const parsed = this.parseValue(value)
this.value.set(parsed)
// In case value.set() does not trigger onValueUpdated, we need to reset
// the counter here also.
this._noChangedEvent = false;
}
override onPropertyChanged(property: string, value: any, prevValue?: any) {
if (this.shownOutputProperties != null) {
const data = this.shownOutputProperties[property]
if (data)
this.setOutputData(data.index, value)
}
}
/*
* Logic to run if this widget can be treated as output (slider, combo, text)
*/
override onExecute(param: any, options: object) {
if (this.inputIndex != null) {
if (this.inputs.length >= this.inputIndex) {
const data = this.getInputData(this.inputIndex)
if (data != null) { // TODO can "null" be a legitimate value here?
this.setValue(data)
}
}
}
if (this.outputIndex != null) {
if (this.outputs.length >= this.outputIndex) {
this.setOutputData(this.outputIndex, get(this.value))
}
}
for (const propName in this.shownOutputProperties) {
const data = this.shownOutputProperties[propName]
this.setOutputData(data.index, this.properties[propName])
}
// Fire a pending change event after one full step of the graph has
// finished processing
if (this._aboutToChange > 0) {
this._aboutToChange -= 1
if (this._aboutToChange <= 0) {
const value = this._aboutToChangeValue;
this._aboutToChange = 0;
this._aboutToChangeValue = null;
this.triggerChangeEvent(value);
}
}
}
override onAction(action: any, param: any, options: { action_call?: string }) {
if (action === this.storeActionName) {
let noChangedEvent = false;
let value = param;
if (param != null && typeof param === "object" && "value" in param) {
value = param.value
if ("noChangedEvent" in param)
noChangedEvent = Boolean(param.noChangedEvent)
}
this.setValue(value, noChangedEvent)
}
}
onConnectOutput(
outputIndex: number,
inputType: INodeInputSlot["type"],
input: INodeInputSlot,
inputNode: LGraphNode,
inputIndex: number
): boolean {
const anyConnected = range(this.outputs.length).some(i => this.getOutputLinks(i).length > 0);
if (this.autoConfig && "config" in input && !anyConnected && (input as IComfyInputSlot).widgetNodeType === this.type) {
this.doAutoConfig(input as IComfyInputSlot)
}
return true;
}
doAutoConfig(input: IComfyInputSlot, options: AutoConfigOptions = { setDefaultValue: true, setWidgetTitle: true }) {
// Copy properties from default config in input slot
const comfyInput = input as IComfyInputSlot;
for (const key in comfyInput.config) {
if (options.includeProperties == null || options.includeProperties.has(key))
this.setProperty(key, comfyInput.config[key])
}
if (options.setDefaultValue) {
if ("defaultValue" in this.properties)
this.setValue(this.properties.defaultValue)
}
if (options.setWidgetTitle) {
const widget = layoutState.findLayoutForNode(this.id as NodeID)
if (widget && input.name !== "") {
widget.attrs.title = input.name;
}
}
// console.debug("Property copy", input, this.properties)
this.setValue(get(this.value))
this.onAutoConfig(input);
this.notifyPropsChanged();
}
onAutoConfig(input: IComfyInputSlot) {
}
notifyPropsChanged() {
const layoutEntry = layoutState.findLayoutEntryForNode(this.id as NodeID)
if (layoutEntry && layoutEntry.parent) {
layoutEntry.parent.attrsChanged.set(get(layoutEntry.parent.attrsChanged) + 1)
}
// console.debug("propsChanged", this)
this.propsChanged.set(get(this.propsChanged) + 1)
}
override onConnectionsChange(
type: LConnectionKind,
slotIndex: number,
isConnected: boolean,
link: LLink,
ioSlot: (INodeOutputSlot | INodeInputSlot)
): void {
super.onConnectionsChange(type, slotIndex, isConnected, link, ioSlot);
this.clampConfig();
}
clampConfig() {
let changed = false;
for (const link of this.getOutputLinks(0)) {
if (link) { // can be undefined if the link is removed
const node = this.graph._nodes_by_id[link.target_id]
if (node) {
const input = node.inputs[link.target_slot]
if (input && "config" in input) {
this.clampOneConfig(input as IComfyInputSlot)
changed = true;
}
}
}
}
// Force reactivity change so the frontend can be updated with the new props
this.notifyPropsChanged();
}
clampOneConfig(input: IComfyInputSlot) { }
override onSerialize(o: SerializedLGraphNode) {
(o as any).comfyValue = get(this.value);
(o as any).shownOutputProperties = this.shownOutputProperties
super.onSerialize(o);
}
override onConfigure(o: SerializedLGraphNode) {
const value = (o as any).comfyValue || LiteGraph.cloneObject(this.defaultValue);
this.value.set(value);
this.shownOutputProperties = (o as any).shownOutputProperties;
}
override stripUserState(o: SerializedLGraphNode) {
super.stripUserState(o);
(o as any).comfyValue = this.defaultValue;
o.properties.defaultValue = null;
}
}
export interface ComfySliderProperties extends ComfyWidgetProperties {
min: number,
max: number,
step: number,
precision: number
}
export class ComfySliderNode extends ComfyWidgetNode<number> {
override properties: ComfySliderProperties = {
tags: [],
defaultValue: 0,
min: 0,
max: 10,
step: 1,
precision: 1
}
override svelteComponentType = RangeWidget
override defaultValue = 0;
static slotLayout: SlotLayout = {
inputs: [
{ name: "value", type: "number" },
{ name: "store", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "value", type: "number" },
{ name: "changed", type: BuiltInSlotType.EVENT },
]
}
override outputProperties = [
{ name: "min", type: "number" },
{ name: "max", type: "number" },
{ name: "step", type: "number" },
{ name: "precision", type: "number" },
]
constructor(name?: string) {
super(name, 0)
}
override parseValue(value: any): number {
if (typeof value !== "number")
return this.properties.min;
return clamp(value, this.properties.min, this.properties.max)
}
override clampOneConfig(input: IComfyInputSlot) {
// this.setProperty("min", clamp(this.properties.min, input.config.min, input.config.max))
// this.setProperty("max", clamp(this.properties.max, input.config.max, input.config.min))
// this.setProperty("step", Math.min(this.properties.step, input.config.step))
this.setValue(this.properties.defaultValue)
}
}
LiteGraph.registerNodeType({
class: ComfySliderNode,
title: "UI.Slider",
desc: "Slider outputting a number value",
type: "ui/slider"
})
export interface ComfyComboProperties extends ComfyWidgetProperties {
values: string[]
/* JS Function body that takes a parameter named "value" as a parameter and returns the label for each combo entry */
convertValueToLabelCode: string
}
export class ComfyComboNode extends ComfyWidgetNode<string> {
override properties: ComfyComboProperties = {
tags: [],
defaultValue: "A",
values: ["A", "B", "C", "D"],
convertValueToLabelCode: ""
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "value", type: "string" },
{ name: "store", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "value", type: "string" },
{ name: "changed", type: BuiltInSlotType.EVENT }
]
}
override svelteComponentType = ComboWidget
override defaultValue = "A";
override saveUserState = false;
// True if at least one combo box refresh has taken place
// Wait until the initial graph load for combo to be valid.
firstLoad: Writable<boolean>;
valuesForCombo: Writable<any[] | null>; // Changed when the combo box has values.
constructor(name?: string) {
super(name, "A")
this.firstLoad = writable(false)
this.valuesForCombo = writable(null)
}
override onPropertyChanged(property: any, value: any) {
if (property === "values" || property === "convertValueToLabelCode") {
// this.formatValues(this.properties.values)
}
}
formatValues(values: string[]) {
if (values == null)
return;
this.properties.values = values;
let formatter: any;
if (this.properties.convertValueToLabelCode)
formatter = new Function("value", this.properties.convertValueToLabelCode) as (v: string) => string;
else
formatter = (value: any) => `${value}`;
let valuesForCombo = []
try {
valuesForCombo = this.properties.values.map((value, index) => {
return {
value,
label: formatter(value),
index
}
})
}
catch (err) {
console.error("Failed formatting!", err)
valuesForCombo = this.properties.values.map((value, index) => {
return {
value,
label: `${value}`,
index
}
})
}
this.firstLoad.set(true)
this.valuesForCombo.set(valuesForCombo);
}
onConnectOutput(
outputIndex: number,
inputType: INodeInputSlot["type"],
input: INodeInputSlot,
inputNode: LGraphNode,
inputIndex: number
): boolean {
if (!super.onConnectOutput(outputIndex, inputType, input, inputNode, inputIndex))
return false;
const thisProps = this.properties;
if (!("config" in input))
return true;
const comfyInput = input as IComfyInputSlot;
const otherProps = comfyInput.config;
// Ensure combo options match
if (!(otherProps.values instanceof Array))
return false;
if (thisProps.values.find((v, i) => otherProps.values.indexOf(v) === -1))
return false;
return true;
}
override parseValue(value: any): string {
if (typeof value !== "string" || this.properties.values.indexOf(value) === -1)
return this.properties.values[0]
return value
}
override clampOneConfig(input: IComfyInputSlot) {
if (!input.config.values)
this.setValue("")
else if (input.config.values.indexOf(this.properties.value) === -1) {
if (input.config.values.length === 0)
this.setValue("")
else
this.setValue(input.config.defaultValue || input.config.values[0])
}
}
override onSerialize(o: SerializedLGraphNode) {
super.onSerialize(o);
// TODO fix saving combo nodes with huge values lists
o.properties.values = []
}
override stripUserState(o: SerializedLGraphNode) {
super.stripUserState(o);
o.properties.values = []
}
}
LiteGraph.registerNodeType({
class: ComfyComboNode,
title: "UI.Combo",
desc: "Combo box outputting a string value",
type: "ui/combo"
})
export interface ComfyTextProperties extends ComfyWidgetProperties {
multiline: boolean;
}
export class ComfyTextNode extends ComfyWidgetNode<string> {
override properties: ComfyTextProperties = {
tags: [],
defaultValue: "",
multiline: false
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "value", type: "string" },
{ name: "store", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "value", type: "string" },
{ name: "changed", type: BuiltInSlotType.EVENT }
]
}
override svelteComponentType = TextWidget
override defaultValue = "";
constructor(name?: string) {
super(name, "")
}
override parseValue(value: any): string {
return `${value}`
}
}
LiteGraph.registerNodeType({
class: ComfyTextNode,
title: "UI.Text",
desc: "Textbox outputting a string value",
type: "ui/text"
})
/** Raw output as received from ComfyUI's backend */
export interface ComfyExecutionResult {
// Technically this response can contain arbitrary data, but "images" is the
// most frequently used as it's output by LoadImage and PreviewImage, the
// only two output nodes in base ComfyUI.
images: ComfyImageLocation[] | null,
}
/** Raw output entry as received from ComfyUI's backend */
export type ComfyImageLocation = {
/* Filename with extension in the subfolder. */
filename: string,
/* Subfolder in the containing folder. */
subfolder: string,
/* Base ComfyUI folder where the image is located. */
type: ComfyUploadImageType
}
export interface ComfyGalleryProperties extends ComfyWidgetProperties {
index: number,
updateMode: "replace" | "append",
}
export class ComfyGalleryNode extends ComfyWidgetNode<ComfyBoxImageMetadata[]> {
override properties: ComfyGalleryProperties = {
tags: [],
defaultValue: [],
index: 0,
updateMode: "replace",
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "images", type: "OUTPUT" },
{ name: "store", type: BuiltInSlotType.ACTION, options: { color_off: "rebeccapurple", color_on: "rebeccapurple" } }
],
outputs: [
{ name: "images", type: "COMFYBOX_IMAGES" },
{ name: "selected_index", type: "number" },
]
}
static propertyLayout: PropertyLayout = [
{ name: "updateMode", defaultValue: "replace", type: "enum", options: { values: ["replace", "append"] } }
]
override svelteComponentType = GalleryWidget
override defaultValue = []
override inputIndex = null;
override saveUserState = false;
override outputIndex = null;
override changedIndex = null;
selectedFilename: string | null = null;
selectedIndexWidget: ITextWidget;
modeWidget: IComboWidget;
constructor(name?: string) {
super(name, [])
this.selectedIndexWidget = this.addWidget("text", "Selected", String(this.properties.index), "index")
this.selectedIndexWidget.disabled = true;
this.modeWidget = this.addWidget("combo", "Mode", this.properties.updateMode, null, { property: "updateMode", values: ["replace", "append"] })
}
override onPropertyChanged(property: any, value: any) {
if (property === "updateMode") {
this.modeWidget.value = value;
}
}
override onExecute() {
this.setOutputData(0, get(this.value))
this.setOutputData(1, this.properties.index)
}
override onAction(action: any, param: any, options: { action_call?: string }) {
super.onAction(action, param, options)
}
override formatValue(value: ComfyBoxImageMetadata[] | null): string {
return `Images: ${value?.length || 0}`
}
override parseValue(param: any): ComfyBoxImageMetadata[] {
const meta = parseWhateverIntoImageMetadata(param) || [];
console.debug("[ComfyGalleryNode] Received output!", param)
if (this.properties.updateMode === "append") {
const currentValue = get(this.value)
return currentValue.concat(meta)
}
else {
return meta;
}
}
override setValue(value: any, noChangedEvent: boolean = false) {
super.setValue(value, noChangedEvent)
this.setProperty("index", null)
}
}
LiteGraph.registerNodeType({
class: ComfyGalleryNode,
title: "UI.Gallery",
desc: "Gallery that shows most recent outputs",
type: "ui/gallery"
})
export interface ComfyButtonProperties extends ComfyWidgetProperties {
param: string
}
export class ComfyButtonNode extends ComfyWidgetNode<boolean> {
override properties: ComfyButtonProperties = {
tags: [],
defaultValue: false,
param: "bang"
}
static slotLayout: SlotLayout = {
outputs: [
{ name: "clicked", type: BuiltInSlotType.EVENT },
{ name: "isClicked", type: "boolean" },
]
}
override svelteComponentType = ButtonWidget;
override defaultValue = false;
override outputIndex = 1;
constructor(name?: string) {
super(name, false)
}
override parseValue(param: any): boolean {
return Boolean(param);
}
onClick() {
this.setValue(true)
this.triggerSlot(0, this.properties.param);
this.setValue(false) // TODO onRelease
}
}
LiteGraph.registerNodeType({
class: ComfyButtonNode,
title: "UI.Button",
desc: "Button that triggers an event when clicked",
type: "ui/button"
})
export interface ComfyCheckboxProperties extends ComfyWidgetProperties {
}
export class ComfyCheckboxNode extends ComfyWidgetNode<boolean> {
override properties: ComfyCheckboxProperties = {
tags: [],
defaultValue: false,
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "value", type: "boolean" },
{ name: "store", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "value", type: "boolean" },
{ name: "changed", type: BuiltInSlotType.EVENT },
]
}
override svelteComponentType = CheckboxWidget;
override defaultValue = false;
override changedIndex = 1;
constructor(name?: string) {
super(name, false)
}
override parseValue(param: any) {
return Boolean(param);
}
}
LiteGraph.registerNodeType({
class: ComfyCheckboxNode,
title: "UI.Checkbox",
desc: "Checkbox that stores a boolean value",
type: "ui/checkbox"
})
export interface ComfyRadioProperties extends ComfyWidgetProperties {
choices: string[]
}
export class ComfyRadioNode extends ComfyWidgetNode<string> {
override properties: ComfyRadioProperties = {
tags: [],
choices: ["Choice A", "Choice B", "Choice C"],
defaultValue: "Choice A",
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "value", type: "string,number" },
{ name: "store", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "value", type: "string" },
{ name: "index", type: "number" },
{ name: "changed", type: BuiltInSlotType.EVENT },
]
}
override svelteComponentType = RadioWidget;
override defaultValue = "";
override changedIndex = 2;
indexWidget: INumberWidget;
index = 0;
constructor(name?: string) {
super(name, "Choice A")
this.indexWidget = this.addWidget("number", "Index", this.index)
this.indexWidget.disabled = true;
}
override onExecute(param: any, options: object) {
super.onExecute(param, options);
this.setOutputData(1, this.index)
}
override setValue(value: string, noChangedEvent: boolean = false) {
super.setValue(value, noChangedEvent)
value = get(this.value);
const index = this.properties.choices.indexOf(value)
if (index === -1)
return;
this.index = index;
this.indexWidget.value = index;
this.setOutputData(1, this.index)
}
override parseValue(param: any): string {
if (typeof param === "string") {
if (this.properties.choices.indexOf(param) === -1)
return this.properties.choices[0]
return param
}
else {
const index = clamp(parseInt(param), 0, this.properties.choices.length - 1)
return this.properties.choices[index] || this.properties.defaultValue
}
}
}
LiteGraph.registerNodeType({
class: ComfyRadioNode,
title: "UI.Radio",
desc: "Radio that outputs a string and index",
type: "ui/radio"
})
export interface ComfyImageEditorNodeProperties extends ComfyWidgetProperties {
}
export class ComfyImageEditorNode extends ComfyWidgetNode<ComfyBoxImageMetadata[]> {
override properties: ComfyImageEditorNodeProperties = {
defaultValue: [],
tags: [],
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "store", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "images", type: "COMFYBOX_IMAGES" }, // TODO support batches
{ name: "changed", type: BuiltInSlotType.EVENT },
]
}
override svelteComponentType = ImageEditorWidget;
override defaultValue = [];
override outputIndex = 0;
override inputIndex = null;
override changedIndex = 1;
override storeActionName = "store";
override saveUserState = false;
constructor(name?: string) {
super(name, [])
}
override parseValue(value: any): ComfyBoxImageMetadata[] {
return parseWhateverIntoImageMetadata(value) || [];
}
override formatValue(value: GradioFileData[]): string {
return `Images: ${value?.length || 0}`
}
}
LiteGraph.registerNodeType({
class: ComfyImageEditorNode,
title: "UI.ImageEditor",
desc: "Widget that lets you upload and edit a multi-layered image. Can also act like a standalone image uploader.",
type: "ui/image_editor"
})

View File

@@ -1,5 +1,4 @@
export { default as ComfyReroute } from "./ComfyReroute"
export { ComfyWidgetNode, ComfySliderNode, ComfyComboNode, ComfyTextNode } from "./ComfyWidgetNodes"
export {
ComfyQueueEvents,
ComfyCopyAction,
@@ -17,4 +16,3 @@ export { default as ComfySelector } from "./ComfySelector"
export { default as ComfyTriggerNewEventNode } from "./ComfyTriggerNewEventNode"
export { default as ComfyConfigureQueuePromptButton } from "./ComfyConfigureQueuePromptButton"
export { default as ComfyPickImageNode } from "./ComfyPickImageNode"
export { default as ComfyImageToFilepathNode } from "./ComfyImageToFilepathNode"

View File

@@ -0,0 +1,48 @@
import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core";
import ButtonWidget from "$lib/widgets/ButtonWidget.svelte";
import ComfyWidgetNode, { type ComfyWidgetProperties } from "./ComfyWidgetNode";
export interface ComfyButtonProperties extends ComfyWidgetProperties {
param: string
}
export default class ComfyButtonNode extends ComfyWidgetNode<boolean> {
override properties: ComfyButtonProperties = {
tags: [],
defaultValue: false,
param: "bang"
}
static slotLayout: SlotLayout = {
outputs: [
{ name: "clicked", type: BuiltInSlotType.EVENT },
]
}
override svelteComponentType = ButtonWidget;
override defaultValue = false;
override outputSlotName = null;
override changedEventName = null;
constructor(name?: string) {
super(name, false)
}
override parseValue(param: any): boolean {
return Boolean(param);
}
onClick() {
this.setValue(true)
this.triggerSlot(0, this.properties.param);
this.setValue(false) // TODO onRelease
}
}
LiteGraph.registerNodeType({
class: ComfyButtonNode,
title: "UI.Button",
desc: "Button that triggers an event when clicked",
type: "ui/button"
})

View File

@@ -0,0 +1,43 @@
import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core";
import CheckboxWidget from "$lib/widgets/CheckboxWidget.svelte";
import type { ComfyWidgetProperties } from "./ComfyWidgetNode";
import ComfyWidgetNode from "./ComfyWidgetNode";
export interface ComfyCheckboxProperties extends ComfyWidgetProperties {
}
export default class ComfyCheckboxNode extends ComfyWidgetNode<boolean> {
override properties: ComfyCheckboxProperties = {
tags: [],
defaultValue: false,
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "store", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "value", type: "boolean" },
{ name: "changed", type: BuiltInSlotType.EVENT },
]
}
override svelteComponentType = CheckboxWidget;
override defaultValue = false;
constructor(name?: string) {
super(name, false)
}
override parseValue(param: any) {
return Boolean(param);
}
}
LiteGraph.registerNodeType({
class: ComfyCheckboxNode,
title: "UI.Checkbox",
desc: "Checkbox that stores a boolean value",
type: "ui/checkbox"
})

View File

@@ -0,0 +1,173 @@
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import { BuiltInSlotType, LiteGraph, type INodeInputSlot, type LGraphNode, type SerializedLGraphNode, type SlotLayout } from "@litegraph-ts/core";
import { get, writable, type Writable } from "svelte/store";
import ComboWidget from "$lib/widgets/ComboWidget.svelte";
import type { ComfyWidgetProperties } from "./ComfyWidgetNode";
import ComfyWidgetNode from "./ComfyWidgetNode";
export interface ComfyComboProperties extends ComfyWidgetProperties {
values: string[]
/* JS Function body that takes a parameter named "value" as a parameter and returns the label for each combo entry */
convertValueToLabelCode: string
}
export default class ComfyComboNode extends ComfyWidgetNode<string> {
override properties: ComfyComboProperties = {
tags: [],
defaultValue: "A",
values: ["A", "B", "C", "D"],
convertValueToLabelCode: ""
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "store", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "value", type: "string" },
{ name: "changed", type: BuiltInSlotType.EVENT }
]
}
override svelteComponentType = ComboWidget
override defaultValue = "A";
override saveUserState = false;
// True if at least one combo box refresh has taken place
// Wait until the initial graph load for combo to be valid.
firstLoad: Writable<boolean>;
lightUp: Writable<boolean>;
valuesForCombo: Writable<any[] | null>; // Changed when the combo box has values.
constructor(name?: string) {
super(name, "A")
this.firstLoad = writable(false)
this.lightUp = writable(true)
this.valuesForCombo = writable(null)
}
override onPropertyChanged(property: any, value: any) {
if (property === "values" || property === "convertValueToLabelCode") {
// this.formatValues(this.properties.values)
}
}
formatValues(values: string[], defaultValue?: string, lightUp: boolean = false) {
if (values == null)
return;
let changed = this.properties.values != values;
this.properties.values = values;
const oldValue = get(this.value)
if (this.properties.values.indexOf(oldValue) === -1) {
changed = true;
this.value.set(defaultValue || this.properties.values[0])
}
if (lightUp && get(this.firstLoad) && changed)
this.lightUp.set(true)
let formatter: any;
if (this.properties.convertValueToLabelCode)
formatter = new Function("value", this.properties.convertValueToLabelCode) as (v: string) => string;
else
formatter = (value: any) => `${value}`;
let valuesForCombo = []
try {
valuesForCombo = this.properties.values.map((value, index) => {
return {
value,
label: formatter(value),
index
}
})
}
catch (err) {
console.error("Failed formatting!", err)
valuesForCombo = this.properties.values.map((value, index) => {
return {
value,
label: `${value}`,
index
}
})
}
this.firstLoad.set(true)
this.valuesForCombo.set(valuesForCombo);
}
onConnectOutput(
outputIndex: number,
inputType: INodeInputSlot["type"],
input: INodeInputSlot,
inputNode: LGraphNode,
inputIndex: number
): boolean {
if (!super.onConnectOutput(outputIndex, inputType, input, inputNode, inputIndex))
return false;
const thisProps = this.properties;
if (!("config" in input))
return true;
const comfyInput = input as IComfyInputSlot;
const otherProps = comfyInput.config;
console.warn("CHECK COMBO CONNECTION", otherProps, thisProps)
// Ensure combo options match
if (!(otherProps.values instanceof Array) || otherProps.values.length === 0)
return false;
thisProps.values = Array.from(otherProps.values);
const value = get(this.value)
if (thisProps.values.indexOf(value) === -1)
this.setValue(otherProps.defaultValue || thisProps.values[0])
console.warn("PASSED")
return true;
}
override parseValue(value: any): string {
if (typeof value !== "string" || this.properties.values.indexOf(value) === -1)
return this.properties.values[0]
return value
}
override clampOneConfig(input: IComfyInputSlot) {
if (!input.config.values)
this.setValue("")
else if (input.config.values.indexOf(this.properties.value) === -1) {
if (input.config.values.length === 0)
this.setValue("")
else
this.setValue(input.config.defaultValue || input.config.values[0])
}
}
override onSerialize(o: SerializedLGraphNode) {
super.onSerialize(o);
// TODO fix saving combo nodes with huge values lists
o.properties.values = []
}
override stripUserState(o: SerializedLGraphNode) {
super.stripUserState(o);
o.properties.values = []
}
}
LiteGraph.registerNodeType({
class: ComfyComboNode,
title: "UI.Combo",
desc: "Combo box outputting a string value",
type: "ui/combo"
})

View File

@@ -0,0 +1,113 @@
import { parseWhateverIntoImageMetadata, type ComfyBoxImageMetadata, type ComfyUploadImageType } from "$lib/utils";
import { BuiltInSlotType, LiteGraph, type IComboWidget, type ITextWidget, type PropertyLayout, type SlotLayout } from "@litegraph-ts/core";
import { get, writable, type Writable } from "svelte/store";
import GalleryWidget from "$lib/widgets/GalleryWidget.svelte";
import type { ComfyWidgetProperties } from "./ComfyWidgetNode";
import ComfyWidgetNode from "./ComfyWidgetNode";
export interface ComfyGalleryProperties extends ComfyWidgetProperties {
index: number | null,
updateMode: "replace" | "append",
}
export default class ComfyGalleryNode extends ComfyWidgetNode<ComfyBoxImageMetadata[]> {
override properties: ComfyGalleryProperties = {
tags: [],
defaultValue: [],
index: 0,
updateMode: "replace",
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "images", type: "OUTPUT" },
{ name: "store", type: BuiltInSlotType.ACTION, options: { color_off: "rebeccapurple", color_on: "rebeccapurple" } }
],
outputs: [
{ name: "images", type: "COMFYBOX_IMAGES" },
{ name: "selected_index", type: "number" },
]
}
static propertyLayout: PropertyLayout = [
{ name: "updateMode", defaultValue: "replace", type: "enum", options: { values: ["replace", "append"] } }
]
override svelteComponentType = GalleryWidget
override defaultValue = []
override saveUserState = false;
override outputSlotName = null;
override changedEventName = null;
selectedFilename: string | null = null;
selectedIndexWidget: ITextWidget;
modeWidget: IComboWidget;
imageWidth: Writable<number> = writable(0);
imageHeight: Writable<number> = writable(0);
constructor(name?: string) {
super(name, [])
this.selectedIndexWidget = this.addWidget("text", "Selected", String(this.properties.index), "index")
this.selectedIndexWidget.disabled = true;
this.modeWidget = this.addWidget("combo", "Mode", this.properties.updateMode, null, { property: "updateMode", values: ["replace", "append"] })
}
override onPropertyChanged(property: any, value: any) {
if (property === "updateMode") {
this.modeWidget.value = value;
}
}
override onExecute() {
const value = get(this.value)
this.setOutputData(0, value)
this.setOutputData(1, this.properties.index)
if (this.properties.index != null && value && value[this.properties.index] != null) {
const image = value[this.properties.index];
image.width = get(this.imageWidth)
image.height = get(this.imageHeight)
}
}
override onAction(action: any, param: any, options: { action_call?: string }) {
super.onAction(action, param, options)
}
override formatValue(value: ComfyBoxImageMetadata[] | null): string {
return `Images: ${value?.length || 0}`
}
override parseValue(param: any): ComfyBoxImageMetadata[] {
if (param == null)
return []
const meta = parseWhateverIntoImageMetadata(param) || [];
console.debug("[ComfyGalleryNode] Received output!", param)
if (this.properties.updateMode === "append") {
const currentValue = get(this.value)
return currentValue.concat(meta)
}
else {
this.notifyPropsChanged();
return meta;
}
}
override setValue(value: any, noChangedEvent: boolean = false) {
super.setValue(value, noChangedEvent)
this.setProperty("index", null)
}
}
LiteGraph.registerNodeType({
class: ComfyGalleryNode,
title: "UI.Gallery",
desc: "Gallery that shows most recent outputs",
type: "ui/gallery"
})

View File

@@ -0,0 +1,65 @@
import { parseWhateverIntoImageMetadata, type ComfyBoxImageMetadata } from "$lib/utils";
import type { FileData as GradioFileData } from "@gradio/upload";
import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core";
import ImageUploadWidget from "$lib/widgets/ImageUploadWidget.svelte";
import type { ComfyWidgetProperties } from "./ComfyWidgetNode";
import ComfyWidgetNode from "./ComfyWidgetNode";
import { get, writable, type Writable } from "svelte/store";
export interface ComfyImageUploadNodeProperties extends ComfyWidgetProperties {
}
export default class ComfyImageUploadNode extends ComfyWidgetNode<ComfyBoxImageMetadata[]> {
properties: ComfyImageUploadNodeProperties = {
defaultValue: [],
tags: [],
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "store", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "images", type: "COMFYBOX_IMAGES" }, // TODO support batches
{ name: "changed", type: BuiltInSlotType.EVENT },
]
}
override svelteComponentType = ImageUploadWidget;
override defaultValue = [];
override outputSlotName = "images";
override storeActionName = "store";
override saveUserState = false;
imgWidth: Writable<number> = writable(0);
imgHeight: Writable<number> = writable(0);
constructor(name?: string) {
super(name, [])
}
override onExecute() {
// TODO better way of getting image size?
const value = get(this.value)
if (value && value.length > 0) {
value[0].width = get(this.imgWidth)
value[0].height = get(this.imgHeight)
}
}
override parseValue(value: any): ComfyBoxImageMetadata[] {
return parseWhateverIntoImageMetadata(value) || [];
}
override formatValue(value: GradioFileData[]): string {
return `Images: ${value?.length || 0}`
}
}
LiteGraph.registerNodeType({
class: ComfyImageUploadNode,
title: "UI.ImageUpload",
desc: "Widget that lets you upload and edit a multi-layered image. Can also act like a standalone image uploader.",
type: "ui/image_upload"
})

View File

@@ -0,0 +1,69 @@
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import { clamp } from "$lib/utils";
import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core";
import NumberWidget from "$lib/widgets/NumberWidget.svelte";
import type { ComfyWidgetProperties } from "./ComfyWidgetNode";
import ComfyWidgetNode from "./ComfyWidgetNode";
export interface ComfyNumberProperties extends ComfyWidgetProperties {
min: number,
max: number,
step: number,
precision: number
}
export default class ComfyNumberNode extends ComfyWidgetNode<number> {
override properties: ComfyNumberProperties = {
tags: [],
defaultValue: 0,
min: 0,
max: 10,
step: 1,
precision: 1
}
override svelteComponentType = NumberWidget
override defaultValue = 0;
static slotLayout: SlotLayout = {
inputs: [
{ name: "store", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "value", type: "number" },
{ name: "changed", type: BuiltInSlotType.EVENT },
]
}
override outputProperties = [
{ name: "min", type: "number" },
{ name: "max", type: "number" },
{ name: "step", type: "number" },
{ name: "precision", type: "number" },
]
constructor(name?: string) {
super(name, 0)
}
override parseValue(value: any): number {
if (typeof value !== "number")
return this.properties.min;
return clamp(value, this.properties.min, this.properties.max)
}
override clampOneConfig(input: IComfyInputSlot) {
// this.setProperty("min", clamp(this.properties.min, input.config.min, input.config.max))
// this.setProperty("max", clamp(this.properties.max, input.config.max, input.config.min))
// this.setProperty("step", Math.min(this.properties.step, input.config.step))
this.setValue(this.properties.defaultValue)
}
}
LiteGraph.registerNodeType({
class: ComfyNumberNode,
title: "UI.Number",
desc: "Displays a number, by default in a slider format.",
type: "ui/number"
})

View File

@@ -0,0 +1,82 @@
import { clamp } from "$lib/utils";
import { BuiltInSlotType, LiteGraph, type INumberWidget, type SlotLayout } from "@litegraph-ts/core";
import { get } from "svelte/store";
import RadioWidget from "$lib/widgets/RadioWidget.svelte";
import type { ComfyWidgetProperties } from "./ComfyWidgetNode";
import ComfyWidgetNode from "./ComfyWidgetNode";
export interface ComfyRadioProperties extends ComfyWidgetProperties {
choices: string[]
}
export default class ComfyRadioNode extends ComfyWidgetNode<string> {
override properties: ComfyRadioProperties = {
tags: [],
choices: ["Choice A", "Choice B", "Choice C"],
defaultValue: "Choice A",
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "store", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "value", type: "string" },
{ name: "index", type: "number" },
{ name: "changed", type: BuiltInSlotType.EVENT },
]
}
override svelteComponentType = RadioWidget;
override defaultValue = "";
indexWidget: INumberWidget;
index = 0;
constructor(name?: string) {
super(name, "Choice A")
this.indexWidget = this.addWidget("number", "Index", this.index)
this.indexWidget.disabled = true;
}
override onExecute(param: any, options: object) {
super.onExecute(param, options);
this.setOutputData(1, this.index)
}
override setValue(value: string, noChangedEvent: boolean = false) {
super.setValue(value, noChangedEvent)
value = get(this.value);
const index = this.properties.choices.indexOf(value)
if (index === -1)
return;
this.index = index;
this.indexWidget.value = index;
this.setOutputData(1, this.index)
}
override parseValue(param: any): string {
if (typeof param === "string") {
if (this.properties.choices.indexOf(param) === -1)
return this.properties.choices[0]
return param
}
else {
const index = clamp(parseInt(param), 0, this.properties.choices.length - 1)
return this.properties.choices[index] || this.properties.defaultValue
}
}
}
LiteGraph.registerNodeType({
class: ComfyRadioNode,
title: "UI.Radio",
desc: "Radio that outputs a string and index",
type: "ui/radio"
})

View File

@@ -0,0 +1,46 @@
import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core";
import TextWidget from "$lib/widgets/TextWidget.svelte";
import ComfyWidgetNode, { type ComfyWidgetProperties } from "./ComfyWidgetNode";
export interface ComfyTextProperties extends ComfyWidgetProperties {
multiline: boolean;
}
export default class ComfyTextNode extends ComfyWidgetNode<string> {
override properties: ComfyTextProperties = {
tags: [],
defaultValue: "",
multiline: false
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "value", type: "string" },
{ name: "store", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "value", type: "string" },
{ name: "changed", type: BuiltInSlotType.EVENT }
]
}
override inputSlotName = "value";
override svelteComponentType = TextWidget
override defaultValue = "";
constructor(name?: string) {
super(name, "")
}
override parseValue(value: any): string {
return `${value}`
}
}
LiteGraph.registerNodeType({
class: ComfyTextNode,
title: "UI.Text",
desc: "Textbox outputting a string value",
type: "ui/text"
})

View File

@@ -0,0 +1,349 @@
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import layoutState from "$lib/stores/layoutState";
import { range } from "$lib/utils";
import { LConnectionKind, LGraphCanvas, LLink, LiteGraph, NodeMode, type INodeInputSlot, type INodeOutputSlot, type ITextWidget, type LGraphNode, type SerializedLGraphNode, type Vector2 } from "@litegraph-ts/core";
import { Watch } from "@litegraph-ts/nodes-basic";
import type { SvelteComponentDev } from "svelte/internal";
import { get, writable, type Unsubscriber, type Writable } from "svelte/store";
import type { ComfyNodeID } from "$lib/api";
import type { ComfyGraphNodeProperties } from "../ComfyGraphNode";
import ComfyGraphNode from "../ComfyGraphNode";
export type AutoConfigOptions = {
includeProperties?: Set<string> | null,
setDefaultValue?: boolean
setWidgetTitle?: boolean
}
/*
* NOTE: If you want to add a new widget but it has the same input/output type
* as another one of the existing widgets, best to create a new "variant" of
* that widget instead.
*
* - Go to layoutState, look for `ALL_ATTRIBUTES,` insert or find a "variant"
* attribute and set `validNodeTypes` to the type of the litegraph node
* - Add a new entry in the `values` array, like "knob" or "dial" for ComfyNumberWidget
* - Add an {#if widget.attrs.variant === <...>} statement in the existing Svelte component
*
* Also, BEWARE of calling setOutputData() and triggerSlot() on the same frame!
* You will have to either implement an internal delay on the event triggering
* or use an Event Delay node to ensure the output slot data can propagate to
* the rest of the graph first (see `delayChangedEvent` for details)
*/
export interface ComfyWidgetProperties extends ComfyGraphNodeProperties {
defaultValue: any
}
/*
* A node that is tied to a UI widget in the frontend. When the frontend's
* widget is changed, the value of the first output in the node is updated
* in the litegraph instance.
*/
export default abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
abstract properties: ComfyWidgetProperties;
value: Writable<T>
propsChanged: Writable<number> = writable(0) // dummy to indicate if props changed
unsubscribe: Unsubscriber;
/** Svelte class for the frontend logic */
abstract svelteComponentType: typeof SvelteComponentDev
/** If false, user manually set min/max/step, and should not be autoinherited from connected input */
autoConfig: boolean = true;
copyFromInputLink: boolean = true;
/**
* If true wait until next frame update to trigger the changed event.
* Reason is, if the event is triggered immediately then other stuff that wants to run
* their own onExecute on the output value won't have completed yet.
*/
delayChangedEvent: boolean = true;
private _aboutToChange: number = 0;
private _aboutToChangeValue: any = null;
private _noChangedEvent: boolean = false;
abstract defaultValue: T;
/** Names of properties to add as inputs */
// shownInputProperties: string[] = []
/** Names of properties to add as outputs */
private shownOutputProperties: Record<string, { type: string, outputName: string }> = {}
outputProperties: { name: string, type: string }[] = []
override isBackendNode = false;
override serialize_widgets = true;
// input slots
inputSlotName: string | null = "value";
storeActionName: string | null = "store";
// output slots
outputSlotName: string | null = "value";
changedEventName: string | null = "changed";
displayWidget: ITextWidget;
override size: Vector2 = [60, 40];
constructor(name: string, value: T) {
const color = LGraphCanvas.node_colors["blue"]
super(name)
this.value = writable(value)
this.color ||= color.color
this.bgColor ||= color.bgColor
this.displayWidget = this.addWidget<ITextWidget>(
"text",
"Value",
""
);
this.displayWidget.disabled = true; // prevent editing
this.unsubscribe = this.value.subscribe(this.onValueUpdated.bind(this))
}
addPropertyAsOutput(propertyName: string, type: string) {
if (this.shownOutputProperties[propertyName])
return;
if (!(propertyName in this.properties)) {
throw `No property named ${propertyName} found!`
}
const outputName = "@" + propertyName;
this.shownOutputProperties[propertyName] = { type, outputName }
this.addOutput(outputName, type)
}
formatValue(value: any): string {
return Watch.toString(value)
}
override changeMode(modeTo: NodeMode): boolean {
const result = super.changeMode(modeTo);
this.notifyPropsChanged();
return result;
}
private onValueUpdated(value: any) {
// console.debug("[Widget] valueUpdated", this, value)
this.displayWidget.value = this.formatValue(value)
if (this.outputSlotName !== null) {
const outputIndex = this.findOutputSlotIndexByName(this.outputSlotName)
if (outputIndex !== -1)
this.setOutputData(outputIndex, get(this.value))
}
if (this.changedEventName !== null && !this._noChangedEvent) {
if (!this.delayChangedEvent)
this.triggerChangeEvent(get(this.value))
else {
// console.debug("[Widget] queueChangeEvent", this, value)
this._aboutToChange = 2; // wait 1.5-2 frames, in case we're already in the middle of executing the graph
this._aboutToChangeValue = get(this.value);
}
}
this._noChangedEvent = false;
}
private triggerChangeEvent(value: any) {
if (this.changedEventName == null)
return;
// console.debug("[Widget] trigger changed", this, value)
this.trigger(this.changedEventName, value)
}
parseValue(value: any): T { return value as T };
getValue(): T {
return get(this.value);
}
setValue(value: any, noChangedEvent: boolean = false) {
if (noChangedEvent)
this._noChangedEvent = true;
const parsed = this.parseValue(value)
this.value.set(parsed)
// In case value.set() does not trigger onValueUpdated, we need to reset
// the counter here also.
this._noChangedEvent = false;
}
override onPropertyChanged(property: string, value: any, prevValue?: any) {
if (this.shownOutputProperties != null) {
const data = this.shownOutputProperties[property]
if (data) {
const index = this.findOutputSlotIndexByName(data.outputName)
if (index !== -1)
this.setOutputData(index, value)
}
}
}
/*
* Logic to run if this widget can be treated as output (slider, combo, text)
*/
override onExecute(param: any, options: object) {
if (this.inputSlotName != null) {
const inputIndex = this.findInputSlotIndexByName(this.inputSlotName)
if (inputIndex !== -1) {
const data = this.getInputData(inputIndex)
if (data != null) { // TODO can "null" be a legitimate value here?
this.setValue(data)
}
}
}
if (this.outputSlotName != null) {
const outputIndex = this.findOutputSlotIndexByName(this.outputSlotName)
if (outputIndex !== -1)
this.setOutputData(outputIndex, get(this.value))
}
for (const propName in this.shownOutputProperties) {
const data = this.shownOutputProperties[propName]
const index = this.findOutputSlotIndexByName(data.outputName)
if (index !== -1)
this.setOutputData(index, this.properties[propName])
}
// Fire a pending change event after one full step of the graph has
// finished processing
if (this._aboutToChange > 0) {
this._aboutToChange -= 1
if (this._aboutToChange <= 0) {
const value = this._aboutToChangeValue;
this._aboutToChange = 0;
this._aboutToChangeValue = null;
this.triggerChangeEvent(value);
}
}
}
override onAction(action: any, param: any, options: { action_call?: string }) {
if (action === this.storeActionName) {
let noChangedEvent = false;
let value = param;
if (param != null && typeof param === "object" && "value" in param) {
value = param.value
if ("noChangedEvent" in param)
noChangedEvent = Boolean(param.noChangedEvent)
}
this.setValue(value, noChangedEvent)
}
}
onConnectOutput(
outputIndex: number,
inputType: INodeInputSlot["type"],
input: INodeInputSlot,
inputNode: LGraphNode,
inputIndex: number
): boolean {
const anyConnected = range(this.outputs.length).some(i => this.getOutputLinks(i).length > 0);
if (this.autoConfig && "config" in input && !anyConnected && (input as IComfyInputSlot).widgetNodeType === this.type) {
this.doAutoConfig(input as IComfyInputSlot)
}
return true;
}
doAutoConfig(input: IComfyInputSlot, options: AutoConfigOptions = { setDefaultValue: true, setWidgetTitle: true }) {
// Copy properties from default config in input slot
const comfyInput = input as IComfyInputSlot;
for (const key in comfyInput.config) {
if (options.includeProperties == null || options.includeProperties.has(key))
this.setProperty(key, comfyInput.config[key])
}
if (options.setDefaultValue) {
if ("defaultValue" in this.properties)
this.setValue(this.properties.defaultValue)
}
if (options.setWidgetTitle) {
const widget = layoutState.findLayoutForNode(this.id as ComfyNodeID)
if (widget && input.name !== "") {
widget.attrs.title = input.name;
}
}
// console.debug("Property copy", input, this.properties)
this.setValue(get(this.value))
this.onAutoConfig(input);
this.notifyPropsChanged();
}
onAutoConfig(input: IComfyInputSlot) {
}
notifyPropsChanged() {
const layoutEntry = layoutState.findLayoutEntryForNode(this.id as ComfyNodeID)
if (layoutEntry && layoutEntry.parent) {
layoutEntry.parent.attrsChanged.set(get(layoutEntry.parent.attrsChanged) + 1)
}
// console.debug("propsChanged", this)
this.propsChanged.set(get(this.propsChanged) + 1)
}
override onConnectionsChange(
type: LConnectionKind,
slotIndex: number,
isConnected: boolean,
link: LLink,
ioSlot: (INodeOutputSlot | INodeInputSlot)
): void {
super.onConnectionsChange(type, slotIndex, isConnected, link, ioSlot);
this.clampConfig();
}
clampConfig() {
let changed = false;
for (const link of this.getOutputLinks(0)) {
if (link) { // can be undefined if the link is removed
const node = this.graph._nodes_by_id[link.target_id]
if (node) {
const input = node.inputs[link.target_slot]
if (input && "config" in input) {
this.clampOneConfig(input as IComfyInputSlot)
changed = true;
}
}
}
}
// Force reactivity change so the frontend can be updated with the new props
this.notifyPropsChanged();
}
clampOneConfig(input: IComfyInputSlot) { }
override onSerialize(o: SerializedLGraphNode) {
(o as any).comfyValue = get(this.value);
(o as any).shownOutputProperties = this.shownOutputProperties
super.onSerialize(o);
}
override onConfigure(o: SerializedLGraphNode) {
const value = (o as any).comfyValue || LiteGraph.cloneObject(this.defaultValue);
this.value.set(value);
this.shownOutputProperties = (o as any).shownOutputProperties;
}
override stripUserState(o: SerializedLGraphNode) {
super.stripUserState(o);
(o as any).comfyValue = this.defaultValue;
o.properties.defaultValue = null;
}
}

View File

@@ -0,0 +1,9 @@
export { default as ComfyWidgetNode } from "./ComfyWidgetNode"
export { default as ComfyButtonNode } from "./ComfyButtonNode"
export { default as ComfyCheckboxNode } from "./ComfyCheckboxNode"
export { default as ComfyComboNode } from "./ComfyComboNode"
export { default as ComfyGalleryNode } from "./ComfyGalleryNode"
export { default as ComfyImageUploadNode } from "./ComfyImageUploadNode"
export { default as ComfyRadioNode } from "./ComfyRadioNode"
export { default as ComfyNumberNode } from "./ComfyNumberNode"
export { default as ComfyTextNode } from "./ComfyTextNode"

View File

@@ -1,11 +1,15 @@
import { get, writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
import type ComfyApp from "$lib/components/ComfyApp"
import { type LGraphNode, type IWidget, type LGraph, NodeMode, type LGraphRemoveNodeOptions, type LGraphAddNodeOptions, type UUID } from "@litegraph-ts/core"
import { type LGraphNode, type IWidget, type LGraph, NodeMode, type LGraphRemoveNodeOptions, type LGraphAddNodeOptions, type UUID, type NodeID, LiteGraph, type GraphIDMapping } from "@litegraph-ts/core"
import { SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import type { ComfyWidgetNode } from '$lib/nodes';
import type { NodeID } from '$lib/api';
import type { ComfyNodeID } from '$lib/api';
import { v4 as uuidv4 } from "uuid";
import type { ComfyWidgetNode } from '$lib/nodes/widgets';
function isComfyWidgetNode(node: LGraphNode): node is ComfyWidgetNode {
return "svelteComponentType" in node
}
type DragItemEntry = {
/*
@@ -60,17 +64,7 @@ export type LayoutState = {
* Items indexed by the litegraph node they're bound to
* Only contains drag items of type "widget"
*/
allItemsByNode: Record<NodeID, DragItemEntry>,
/*
* Selected drag items.
*/
currentSelection: DragItemID[],
/*
* Selected LGraphNodes inside the litegraph canvas.
*/
currentSelectionNodes: LGraphNode[],
allItemsByNode: Record<ComfyNodeID, DragItemEntry>,
/*
* If true, a saved workflow is being deserialized, so ignore any
@@ -194,7 +188,7 @@ export type AttributesSpec = {
location: "widget" | "nodeProps" | "nodeVars" | "workflow"
/*
* Can this attribute be edited in the properties pane.
* Can this attribute be edited in the properties pane?
*/
editable: boolean,
@@ -259,7 +253,12 @@ export type AttributesSpec = {
* This should be used if there's a canShow dependent on this property so
* the pane can be updated with the new list of valid properties.
*/
refreshPanelOnChange?: boolean
refreshPanelOnChange?: boolean,
/*
* Callback run when this value is changed.
*/
onChanged?: (arg: IDragItem | LGraphNode | LayoutState, value: any, prevValue: any) => void,
}
/*
@@ -283,6 +282,24 @@ const deserializeStringArray = (arg: string) => {
return arg.split(",").map(s => s.trim())
}
const setNodeTitle = (arg: IDragItem, value: any) => {
if (arg.type !== "widget")
return
const widget = arg as WidgetLayout;
if (widget.node == null)
return;
const reg = LiteGraph.registered_node_types[widget.node.type];
if (reg == null)
return
if (value && value !== reg.title)
widget.node.title = `${reg.title} (${value})`
else
widget.node.title = reg.title
}
/*
* Attributes that will show up in the properties panel.
* Their order in the list is the order they'll appear in the panel.
@@ -297,6 +314,7 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
location: "widget",
defaultValue: "",
editable: true,
// onChanged: setNodeTitle
},
{
name: "hidden",
@@ -341,7 +359,7 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
location: "widget",
editable: true,
values: ["visible", "disabled", "hidden"],
defaultValue: "disabled",
defaultValue: "hidden",
canShow: (di: IDragItem) => di.type === "widget"
},
@@ -362,7 +380,7 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
location: "widget",
editable: true,
values: ["block", "hidden"],
defaultValue: "block",
defaultValue: "hidden",
canShow: (di: IDragItem) => di.type === "container"
},
@@ -493,7 +511,7 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
defaultValue: 0,
min: -2 ^ 16,
max: 2 ^ 16,
validNodeTypes: ["ui/slider"],
validNodeTypes: ["ui/number"],
},
{
name: "max",
@@ -503,7 +521,7 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
defaultValue: 10,
min: -2 ^ 16,
max: 2 ^ 16,
validNodeTypes: ["ui/slider"],
validNodeTypes: ["ui/number"],
},
{
name: "step",
@@ -513,7 +531,7 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
defaultValue: 1,
min: -2 ^ 16,
max: 2 ^ 16,
validNodeTypes: ["ui/slider"],
validNodeTypes: ["ui/number"],
},
// Button
@@ -579,6 +597,7 @@ for (const cat of Object.values(ALL_ATTRIBUTES)) {
export { ALL_ATTRIBUTES };
// TODO Should be nested by category for name uniqueness?
const defaultWidgetAttributes: Attributes = {} as any
const defaultWorkflowAttributes: LayoutAttributes = {} as any
for (const cat of Object.values(ALL_ATTRIBUTES)) {
@@ -660,11 +679,11 @@ type LayoutStateOps = {
updateChildren: (parent: IDragItem, children: IDragItem[]) => IDragItem[],
nodeAdded: (node: LGraphNode, options: LGraphAddNodeOptions) => void,
nodeRemoved: (node: LGraphNode, options: LGraphRemoveNodeOptions) => void,
groupItems: (dragItems: IDragItem[], attrs?: Partial<Attributes>) => ContainerLayout,
moveItem: (target: IDragItem, to: ContainerLayout, index?: number) => void,
groupItems: (dragItemIDs: DragItemID[], attrs?: Partial<Attributes>) => ContainerLayout,
ungroup: (container: ContainerLayout) => void,
getCurrentSelection: () => IDragItem[],
findLayoutEntryForNode: (nodeId: NodeID) => DragItemEntry | null,
findLayoutForNode: (nodeId: NodeID) => IDragItem | null,
findLayoutEntryForNode: (nodeId: ComfyNodeID) => DragItemEntry | null,
findLayoutForNode: (nodeId: ComfyNodeID) => IDragItem | null,
serialize: () => SerializedLayoutState,
deserialize: (data: SerializedLayoutState, graph: LGraph) => void,
initDefaultLayout: () => void,
@@ -676,8 +695,6 @@ const store: Writable<LayoutState> = writable({
root: null,
allItems: {},
allItemsByNode: {},
currentSelection: [],
currentSelectionNodes: [],
isMenuOpen: false,
isConfiguring: true,
refreshPropsPanel: writable(0),
@@ -686,6 +703,20 @@ const store: Writable<LayoutState> = writable({
}
})
function clear() {
store.set({
root: null,
allItems: {},
allItemsByNode: {},
isMenuOpen: false,
isConfiguring: true,
refreshPropsPanel: writable(0),
attrs: {
...defaultWorkflowAttributes
}
})
}
function findDefaultContainerForInsertion(): ContainerLayout | null {
const state = get(store);
@@ -706,6 +737,16 @@ function findDefaultContainerForInsertion(): ContainerLayout | null {
return null
}
function runOnChangedForWidgetDefaults(dragItem: IDragItem) {
for (const cat of Object.values(ALL_ATTRIBUTES)) {
for (const spec of Object.values(cat.specs)) {
if (defaultWidgetAttributes[spec.name] !== undefined && spec.onChanged != null) {
spec.onChanged(dragItem, dragItem.attrs[spec.name], dragItem.attrs[spec.name])
}
}
}
}
function addContainer(parent: ContainerLayout | null, attrs: Partial<Attributes> = {}, index?: number): ContainerLayout {
const state = get(store);
const dragItem: ContainerLayout = {
@@ -718,19 +759,26 @@ function addContainer(parent: ContainerLayout | null, attrs: Partial<Attributes>
...attrs
}
}
const entry: DragItemEntry = { dragItem, children: [], parent: null };
if (state.allItemsByNode[dragItem.id] != null)
throw new Error(`Container with ID ${dragItem.id} already registered!!!`)
state.allItems[dragItem.id] = entry;
if (parent) {
moveItem(dragItem, parent, index)
}
console.debug("[layoutState] addContainer", state)
store.set(state)
// runOnChangedForWidgetDefaults(dragItem)
return dragItem;
}
function addWidget(parent: ContainerLayout, node: ComfyWidgetNode, attrs: Partial<Attributes> = {}, index?: number): WidgetLayout {
const state = get(store);
const widgetName = "Widget"
const widgetName = node.title || "Widget"
const dragItem: WidgetLayout = {
type: "widget",
id: uuidv4(),
@@ -739,16 +787,23 @@ function addWidget(parent: ContainerLayout, node: ComfyWidgetNode, attrs: Partia
attrs: {
...defaultWidgetAttributes,
title: widgetName,
nodeDisabledState: "disabled",
...attrs
}
}
const parentEntry = state.allItems[parent.id]
const entry: DragItemEntry = { dragItem, children: [], parent: null };
if (state.allItems[dragItem.id] != null)
throw new Error(`Widget with ID ${dragItem.id} already registered!!!`)
state.allItems[dragItem.id] = entry;
if (state.allItemsByNode[node.id] != null)
throw new Error(`Widget's node with ID ${node.id} already registered!!!`)
state.allItemsByNode[node.id] = entry;
console.debug("[layoutState] addWidget", state)
moveItem(dragItem, parent, index)
// runOnChangedForWidgetDefaults(dragItem)
return dragItem;
}
@@ -784,24 +839,62 @@ function removeEntry(state: LayoutState, id: DragItemID) {
}
function nodeAdded(node: LGraphNode, options: LGraphAddNodeOptions) {
// Only concern ourselves with widget nodes
if (!isComfyWidgetNode(node))
return;
const state = get(store)
if (state.isConfiguring)
return;
let attrs: Partial<Attributes> = {}
if (options.addedBy === "moveIntoSubgraph" || options.addedBy === "moveOutOfSubgraph") {
// All we need to do is update the nodeID linked to this node.
const item = state.allItemsByNode[options.prevNodeId]
delete state.allItemsByNode[options.prevNodeId]
const item = state.allItemsByNode[options.prevNodeID]
delete state.allItemsByNode[options.prevNodeID]
state.allItemsByNode[node.id] = item
return;
}
else if ((options.addedBy === "cloneSelection" || options.addedBy === "paste") && options.prevNodeID != null) {
console.warn("WASCLONED", options.addedBy, options.prevNodeID, Object.keys(state.allItemsByNode), options.prevNode, options.subgraphs)
// Grab layout state information and clone it too.
let prevWidget = state.allItemsByNode[node.id]
if (prevWidget == null) {
// If a subgraph was cloned, try looking for the original widget node corresponding to the new widget node being added.
// `node` is the new ComfyWidgetNode instance to copy attrs to.
// `options.cloneData` should contain the results of Subgraph.clone(), called "subgraphNewIDMapping".
// `options.cloneData` is attached to the onNodeAdded options if a node is added to a graph after being
// selection-cloned or pasted, as they both call clone() internally.
const cloneData = options.cloneData.forNode[options.prevNodeID]
if (cloneData && cloneData.subgraphNewIDMapping != null) {
// At this point we know options.prevNodeID points to a subgraph.
const mapping = cloneData.subgraphNewIDMapping as GraphIDMapping
// This mapping is two-way, so oldID -> newID *and* newID -> oldID are supported.
// Take the cloned node's ID and look up what the original node's ID was.
const nodeIDInLayoutState = mapping.nodeIDs[node.id];
if (nodeIDInLayoutState) {
// Gottem.
prevWidget = state.allItemsByNode[nodeIDInLayoutState]
console.warn("FOUND CLONED SUBGRAPH NODE", node.id, "=>", nodeIDInLayoutState, prevWidget)
}
}
}
if (prevWidget) {
console.warn("FOUND", prevWidget.dragItem.attrs)
// XXX: Will this work for most properties?
attrs = structuredClone(prevWidget.dragItem.attrs)
}
}
const parent = findDefaultContainerForInsertion();
console.debug("[layoutState] nodeAdded", node)
if ("svelteComponentType" in node) {
addWidget(parent, node as ComfyWidgetNode);
}
console.debug("[layoutState] nodeAdded", node.id)
addWidget(parent, node, attrs);
}
function nodeRemoved(node: LGraphNode, options: LGraphRemoveNodeOptions) {
@@ -830,7 +923,7 @@ function nodeRemoved(node: LGraphNode, options: LGraphRemoveNodeOptions) {
function moveItem(target: IDragItem, to: ContainerLayout, index?: number) {
const state = get(store)
const entry = state.allItems[target.id]
if (entry.parent && entry.parent.id === to.id)
if (!entry || (entry.parent && entry.parent.id === to.id && entry.children.indexOf(target) === index))
return;
if (entry.parent) {
@@ -858,33 +951,27 @@ function moveItem(target: IDragItem, to: ContainerLayout, index?: number) {
store.set(state)
}
function getCurrentSelection(): IDragItem[] {
const state = get(store)
return state.currentSelection.map(id => state.allItems[id].dragItem)
}
function groupItems(dragItems: IDragItem[], attrs: Partial<Attributes> = {}): ContainerLayout {
if (dragItems.length === 0)
function groupItems(dragItemIDs: DragItemID[], attrs: Partial<Attributes> = {}): ContainerLayout {
if (dragItemIDs.length === 0)
return;
const state = get(store)
const parent = state.allItems[dragItems[0].id].parent || findDefaultContainerForInsertion();
const parent = state.allItems[dragItemIDs[0]].parent || findDefaultContainerForInsertion();
if (parent === null || parent.type !== "container")
return;
let index = undefined;
if (parent) {
const indexFound = state.allItems[parent.id].children.findIndex(c => c.id === dragItems[0].id)
const indexFound = state.allItems[parent.id].children.findIndex(c => c.id === dragItemIDs[0])
if (indexFound !== -1)
index = indexFound
}
const title = dragItems.length <= 1 ? "" : "Group";
const container = addContainer(parent as ContainerLayout, { title: "", containerVariant: "block", ...attrs }, index)
const container = addContainer(parent as ContainerLayout, { title, ...attrs }, index)
for (const item of dragItems) {
for (const itemID of dragItemIDs) {
const item = state.allItems[itemID].dragItem;
moveItem(item, container)
}
@@ -924,7 +1011,7 @@ function ungroup(container: ContainerLayout) {
store.set(state)
}
function findLayoutEntryForNode(nodeId: NodeID): DragItemEntry | null {
function findLayoutEntryForNode(nodeId: ComfyNodeID): DragItemEntry | null {
const state = get(store)
const found = Object.entries(state.allItems).find(pair =>
pair[1].dragItem.type === "widget"
@@ -934,7 +1021,7 @@ function findLayoutEntryForNode(nodeId: NodeID): DragItemEntry | null {
return null;
}
function findLayoutForNode(nodeId: NodeID): WidgetLayout | null {
function findLayoutForNode(nodeId: ComfyNodeID): WidgetLayout | null {
const found = findLayoutEntryForNode(nodeId);
if (!found)
return null;
@@ -946,8 +1033,6 @@ function initDefaultLayout() {
root: null,
allItems: {},
allItemsByNode: {},
currentSelection: [],
currentSelectionNodes: [],
isMenuOpen: false,
isConfiguring: false,
refreshPropsPanel: writable(0),
@@ -1034,7 +1119,9 @@ function deserialize(data: SerializedLayoutState, graph: LGraph) {
if (dragItem.type === "widget") {
const widget = dragItem as WidgetLayout;
widget.node = graph.getNodeById(entry.dragItem.nodeId) as ComfyWidgetNode
widget.node = graph.getNodeByIdRecursive(entry.dragItem.nodeId) as ComfyWidgetNode
if (widget.node == null)
throw (`Node in litegraph not found! ${entry.dragItem.nodeId}`)
allItemsByNode[entry.dragItem.nodeId] = dragEntry
}
}
@@ -1059,8 +1146,6 @@ function deserialize(data: SerializedLayoutState, graph: LGraph) {
root,
allItems,
allItemsByNode,
currentSelection: [],
currentSelectionNodes: [],
isMenuOpen: false,
isConfiguring: false,
refreshPropsPanel: writable(0),
@@ -1091,7 +1176,7 @@ const layoutStateStore: WritableLayoutStateStore =
updateChildren,
nodeAdded,
nodeRemoved,
getCurrentSelection,
moveItem,
groupItems,
findLayoutEntryForNode,
findLayoutForNode,

View File

@@ -1,4 +1,4 @@
import type { ComfyAPIHistoryEntry, ComfyAPIHistoryItem, ComfyAPIHistoryResponse, ComfyAPIQueueResponse, ComfyAPIStatusResponse, ComfyBoxPromptExtraData, NodeID, PromptID } from "$lib/api";
import type { ComfyAPIHistoryEntry, ComfyAPIHistoryItem, ComfyAPIHistoryResponse, ComfyAPIQueueResponse, ComfyAPIStatusResponse, ComfyBoxPromptExtraData, ComfyNodeID, PromptID } from "$lib/api";
import type { Progress, SerializedPromptInputsAll, SerializedPromptOutputs } from "$lib/components/ComfyApp";
import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes";
import notify from "$lib/notify";
@@ -14,12 +14,13 @@ type QueueStateOps = {
queueUpdated: (resp: ComfyAPIQueueResponse) => void,
historyUpdated: (resp: ComfyAPIHistoryResponse) => void,
statusUpdated: (status: ComfyAPIStatusResponse | null) => void,
executingUpdated: (promptID: PromptID | null, runningNodeID: NodeID | null) => void,
executionCached: (promptID: PromptID, nodes: NodeID[]) => void,
executionStart: (promptID: PromptID) => void,
executingUpdated: (promptID: PromptID | null, runningNodeID: ComfyNodeID | null) => void,
executionCached: (promptID: PromptID, nodes: ComfyNodeID[]) => void,
executionError: (promptID: PromptID, message: string) => void,
progressUpdated: (progress: Progress) => void
afterQueued: (promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) => void
onExecuted: (promptID: PromptID, nodeID: NodeID, output: ComfyExecutionResult) => void
onExecuted: (promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) => void
}
export type QueueEntry = {
@@ -30,7 +31,7 @@ export type QueueEntry = {
promptID: PromptID,
prompt: SerializedPromptInputsAll,
extraData: ComfyBoxPromptExtraData,
goodOutputs: NodeID[],
goodOutputs: ComfyNodeID[],
/* Data not sent by ComfyUI's API, lost on page refresh */
@@ -38,8 +39,8 @@ export type QueueEntry = {
outputs: SerializedPromptOutputs,
/* Nodes in of the workflow that have finished running so far. */
nodesRan: Set<NodeID>,
cachedNodes: Set<NodeID>
nodesRan: Set<ComfyNodeID>,
cachedNodes: Set<ComfyNodeID>
}
export type CompletedQueueEntry = {
@@ -54,7 +55,7 @@ export type QueueState = {
queuePending: Writable<QueueEntry[]>,
queueCompleted: Writable<CompletedQueueEntry[]>,
queueRemaining: number | "X" | null;
runningNodeID: NodeID | null;
runningNodeID: ComfyNodeID | null;
progress: Progress | null,
isInterrupting: boolean
}
@@ -161,7 +162,7 @@ function moveToCompleted(index: number, queue: Writable<QueueEntry[]>, status: Q
store.set(state)
}
function executingUpdated(promptID: PromptID, runningNodeID: NodeID | null) {
function executingUpdated(promptID: PromptID, runningNodeID: ComfyNodeID | null) {
console.debug("[queueState] executingUpdated", promptID, runningNodeID)
store.update((s) => {
s.progress = null;
@@ -199,7 +200,7 @@ function executingUpdated(promptID: PromptID, runningNodeID: NodeID | null) {
})
}
function executionCached(promptID: PromptID, nodes: NodeID[]) {
function executionCached(promptID: PromptID, nodes: ComfyNodeID[]) {
console.debug("[queueState] executionCached", promptID, nodes)
store.update(s => {
const [index, entry, queue] = findEntryInPending(promptID);
@@ -235,29 +236,57 @@ function executionError(promptID: PromptID, message: string) {
})
}
function afterQueued(promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) {
console.debug("[queueState] afterQueued", promptID, Object.keys(prompt))
function createNewQueueEntry(promptID: PromptID, number: number = -1, prompt: SerializedPromptInputsAll = {}, extraData: any = {}): QueueEntry {
return {
number,
queuedAt: new Date(), // Now
finishedAt: null,
promptID,
prompt,
extraData,
goodOutputs: [],
outputs: {},
nodesRan: new Set(),
cachedNodes: new Set()
}
}
function executionStart(promptID: PromptID) {
console.debug("[queueState] executionStart", promptID)
store.update(s => {
const entry: QueueEntry = {
number,
queuedAt: new Date(), // Now
finishedAt: null,
promptID,
prompt,
extraData,
goodOutputs: [],
outputs: {},
nodesRan: new Set(),
cachedNodes: new Set()
const [index, entry, queue] = findEntryInPending(promptID);
if (entry == null) {
const entry = createNewQueueEntry(promptID);
s.queuePending.update(qp => { qp.push(entry); return qp })
console.debug("[queueState] ADD PROMPT", promptID)
}
s.queuePending.update(qp => { qp.push(entry); return qp })
console.debug("[queueState] ADD PROMPT", promptID)
s.isInterrupting = false;
return s
})
}
function onExecuted(promptID: PromptID, nodeID: NodeID, output: ComfyExecutionResult) {
function afterQueued(promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) {
console.debug("[queueState] afterQueued", promptID, Object.keys(prompt))
store.update(s => {
const [index, entry, queue] = findEntryInPending(promptID);
if (entry == null) {
const entry = createNewQueueEntry(promptID, number, prompt, extraData);
s.queuePending.update(qp => { qp.push(entry); return qp })
console.debug("[queueState] ADD PROMPT", promptID)
}
else {
entry.number = number;
entry.prompt = prompt
entry.extraData = extraData
queue.set(get(queue))
console.warn("[queueState] UPDATE PROMPT", promptID)
}
s.isInterrupting = false;
return s
})
}
function onExecuted(promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) {
console.debug("[queueState] onExecuted", promptID, nodeID, output)
store.update(s => {
const [index, entry, queue] = findEntryInPending(promptID)
@@ -279,6 +308,7 @@ const queueStateStore: WritableQueueStateStore =
historyUpdated,
statusUpdated,
progressUpdated,
executionStart,
executingUpdated,
executionCached,
executionError,

View File

@@ -0,0 +1,57 @@
import { writable } from 'svelte/store';
import type { Readable, Writable } from 'svelte/store';
import type { DragItemID, IDragItem } from './layoutState';
import type { LGraphNode, NodeID } from '@litegraph-ts/core';
export type SelectionState = {
/*
* Selected drag items.
* NOTE: Order is important, for node grouping actions.
*/
currentSelection: DragItemID[],
/*
* Hovered drag items.
*/
currentHovered: Set<DragItemID>,
/*
* Selected LGraphNodes inside the litegraph canvas.
* NOTE: Order is important, for node grouping actions.
*/
currentSelectionNodes: LGraphNode[],
/*
* Currently hovered nodes.
*/
currentHoveredNodes: Set<NodeID>
}
type SelectionStateOps = {
clear: () => void,
}
export type WritableSelectionStateStore = Writable<SelectionState> & SelectionStateOps;
const store: Writable<SelectionState> = writable(
{
currentSelection: [],
currentSelectionNodes: [],
currentHovered: new Set(),
currentHoveredNodes: new Set(),
})
function clear() {
store.set({
currentSelection: [],
currentSelectionNodes: [],
currentHovered: new Set(),
currentHoveredNodes: new Set(),
})
}
const uiStateStore: WritableSelectionStateStore =
{
...store,
clear
}
export default uiStateStore;

View File

@@ -1,13 +1,10 @@
import ComfyApp, { type SerializedPrompt } from "./components/ComfyApp";
import ComboWidget from "$lib/widgets/ComboWidget.svelte";
import RangeWidget from "$lib/widgets/RangeWidget.svelte";
import TextWidget from "$lib/widgets/TextWidget.svelte";
import { get } from "svelte/store"
import layoutState from "$lib/stores/layoutState"
import type { SvelteComponentDev } from "svelte/internal";
import type { SerializedLGraph } from "@litegraph-ts/core";
import type { FileNameOrGalleryData, ComfyExecutionResult, ComfyImageLocation } from "./nodes/ComfyWidgetNodes";
import layoutState, { type WidgetLayout } from "$lib/stores/layoutState";
import selectionState from "$lib/stores/selectionState";
import type { FileData as GradioFileData } from "@gradio/upload";
import { Subgraph, type LGraph, type LGraphNode, type LLink, type SerializedLGraph, type UUID } from "@litegraph-ts/core";
import { get } from "svelte/store";
import type { ComfyNodeID } from "./api";
import { type SerializedPrompt } from "./components/ComfyApp";
export function clamp(n: number, min: number, max: number): number {
return Math.min(Math.max(n, min), max)
@@ -21,6 +18,13 @@ export function range(size: number, startAt: number = 0): ReadonlyArray<number>
return [...Array(size).keys()].map(i => i + startAt);
}
export function* enumerate<T>(iterable: Iterable<T>): Iterable<[number, T]> {
let index = 0;
for (const value of iterable) {
yield [index++, value];
}
}
export function download(filename: string, text: string, type: string = "text/plain") {
const blob = new Blob([text], { type: type });
const url = URL.createObjectURL(blob);
@@ -37,11 +41,12 @@ export function download(filename: string, text: string, type: string = "text/pl
export function startDrag(evt: MouseEvent) {
const dragItemId: string = evt.target.dataset["dragItemId"];
const ss = get(selectionState)
const ls = get(layoutState)
if (evt.button !== 0) {
if (ls.currentSelection.length <= 1 && !ls.isMenuOpen)
ls.currentSelection = [dragItemId]
if (ss.currentSelection.length <= 1 && !ls.isMenuOpen)
ss.currentSelection = [dragItemId]
return;
}
@@ -50,24 +55,110 @@ export function startDrag(evt: MouseEvent) {
console.debug("startDrag", item)
if (evt.ctrlKey) {
const index = ls.currentSelection.indexOf(item.id)
const index = ss.currentSelection.indexOf(item.id)
if (index === -1)
ls.currentSelection.push(item.id);
ss.currentSelection.push(item.id);
else
ls.currentSelection.splice(index, 1);
ls.currentSelection = ls.currentSelection;
ss.currentSelection.splice(index, 1);
ss.currentSelection = ss.currentSelection;
}
else {
ls.currentSelection = [item.id]
ss.currentSelection = [item.id]
}
ss.currentSelectionNodes = [];
for (const id of ss.currentSelection) {
const item = ls.allItems[id].dragItem
if (item.type === "widget") {
const node = (item as WidgetLayout).node;
if (node) {
ss.currentSelectionNodes.push(node)
}
}
}
ls.currentSelectionNodes = [];
layoutState.set(ls)
selectionState.set(ss)
};
export function stopDrag(evt: MouseEvent) {
};
export function graphToGraphVis(graph: LGraph): string {
let links: string[] = []
let seenLinks = new Set()
let subgraphs: Record<string, [Subgraph, string[]]> = {}
let subgraphNodes: Record<number | UUID, Subgraph> = {}
let idToInt: Record<number | UUID, number> = {}
let curId = 0;
const convId = (id: number | UUID): number => {
if (idToInt[id] == null) {
idToInt[id] = curId++;
}
return idToInt[id];
}
const addLink = (node: LGraphNode, link: LLink): string => {
const nodeA = node.graph.getNodeById(link.origin_id)
const nodeB = node.graph.getNodeById(link.target_id);
seenLinks.add(link.id)
return ` "${convId(nodeA.id)}_${nodeA.title}" -> "${convId(nodeB.id)}_${nodeB.title}";\n`;
}
for (const node of graph.iterateNodesInOrderRecursive()) {
for (let [index, input] of enumerate(node.iterateInputInfo())) {
const link = node.getInputLink(index);
if (link && !seenLinks.has(link.id)) {
const linkText = addLink(node, link)
if (node.graph != graph) {
subgraphs[node.graph._subgraph_node.id] ||= [node.graph._subgraph_node, []]
subgraphs[node.graph._subgraph_node.id][1].push(linkText)
subgraphNodes[node.graph._subgraph_node.id] = node.graph._subgraph_node
}
else {
links.push(linkText)
}
}
}
for (let [index, output] of enumerate(node.iterateOutputInfo())) {
for (const link of node.getOutputLinks(index)) {
if (!seenLinks.has(link.id)) {
const linkText = addLink(node, link)
if (node.graph != graph) {
subgraphs[node.graph._subgraph_node.id] ||= [node.graph._subgraph_node, []]
subgraphs[node.graph._subgraph_node.id][1].push(linkText)
subgraphNodes[node.graph._subgraph_node.id] = node.graph._subgraph_node
}
else {
links.push(linkText)
}
}
}
}
}
let out = "digraph {\n"
out += ' fontname="Helvetica,Arial,sans-serif"\n'
out += ' node [fontname="Helvetica,Arial,sans-serif"]\n'
out += ' edge [fontname="Helvetica,Arial,sans-serif"]\n'
out += ' node [shape=box style=filled fillcolor="#DDDDDD"]\n'
for (const [subgraph, links] of Object.values(subgraphs)) {
// Subgraph name has to be prefixed with "cluster" to show up as a cluster...
out += ` subgraph cluster_subgraph_${convId(subgraph.id)} {\n`
out += ` label="${convId(subgraph.id)}_${subgraph.title}";\n`;
out += " color=red;\n";
// out += " style=grey;\n";
out += " " + links.join(" ")
out += " }\n"
}
out += links.join("")
out += "}"
return out
}
export function workflowToGraphVis(workflow: SerializedLGraph): string {
let out = "digraph {\n"
@@ -87,17 +178,21 @@ export function promptToGraphVis(prompt: SerializedPrompt): string {
for (const pair of Object.entries(prompt.output)) {
const [id, o] = pair;
const outNode = prompt.workflow.nodes.find(n => n.id == id)
for (const pair2 of Object.entries(o.inputs)) {
const [inpName, i] = pair2;
if (outNode) {
for (const pair2 of Object.entries(o.inputs)) {
const [inpName, i] = pair2;
if (Array.isArray(i) && i.length === 2 && typeof i[0] === "string" && typeof i[1] === "number") {
// Link
const inpNode = prompt.workflow.nodes.find(n => n.id == i[0])
out += `"${inpNode.title}" -> "${outNode.title}"\n`
}
else {
// Value
out += `"${id}-${inpName}-${i}" -> "${outNode.title}"\n`
if (Array.isArray(i) && i.length === 2 && typeof i[0] === "string" && typeof i[1] === "number") {
// Link
const inpNode = prompt.workflow.nodes.find(n => n.id == i[0])
if (inpNode) {
out += `"${inpNode.title}" -> "${outNode.title}"\n`
}
}
else {
// Value
out += `"${id}-${inpName}-${i}" -> "${outNode.title}"\n`
}
}
}
}
@@ -106,13 +201,15 @@ export function promptToGraphVis(prompt: SerializedPrompt): string {
return out
}
export function getNodeInfo(nodeId: NodeID): string {
export function getNodeInfo(nodeId: ComfyNodeID): string {
let app = (window as any).app;
if (!app || !app.lGraph)
return String(nodeId);
const title = app.lGraph.getNodeById(nodeId)?.title || String(nodeId);
return title + " (" + nodeId + ")"
const displayNodeID = nodeId ? (nodeId.split("-")[0]) : String(nodeId);
const title = app.lGraph.getNodeByIdRecursive(nodeId)?.title || String(nodeId);
return title + " (" + displayNodeID + ")"
}
export const debounce = (callback: Function, wait = 250) => {
@@ -231,6 +328,24 @@ export async function uploadImageToComfyUI(blob: Blob, filename: string, type: C
});
}
/** Raw output as received from ComfyUI's backend */
export interface ComfyExecutionResult {
// Technically this response can contain arbitrary data, but "images" is the
// most frequently used as it's output by LoadImage and PreviewImage, the
// only two output nodes in base ComfyUI.
images: ComfyImageLocation[] | null,
}
/** Raw output entry as received from ComfyUI's backend */
export type ComfyImageLocation = {
/* Filename with extension in the subfolder. */
filename: string,
/* Subfolder in the containing folder. */
subfolder: string,
/* Base ComfyUI folder where the image is located. */
type: ComfyUploadImageType
}
/*
* Convenient type for passing around image filepaths and their metadata with
* wires. Needs to be converted to a filename for use with LoadImage.
@@ -304,6 +419,12 @@ export function executionResultToImageMetadata(result: ComfyExecutionResult): Co
return result.images.map(comfyFileToComfyBoxMetadata)
}
export function isComfyImageLocation(param: any): param is ComfyImageLocation {
return param != null && typeof param === "object"
&& typeof param.filename === "string"
&& typeof param.type === "string"
}
export function parseWhateverIntoImageMetadata(param: any): ComfyBoxImageMetadata[] | null {
let meta: ComfyBoxImageMetadata[] | null = null
@@ -314,12 +435,26 @@ export function parseWhateverIntoImageMetadata(param: any): ComfyBoxImageMetadat
meta = param
}
else if (isComfyExecutionResult(param)) {
meta = executionResultToImageMetadata(param)
meta = executionResultToImageMetadata(param);
}
else if (isComfyImageLocation(param)) {
meta = [comfyFileToComfyBoxMetadata(param)]
}
else if (Array.isArray(param) && param.every(isComfyImageLocation)) {
meta = param.map(comfyFileToComfyBoxMetadata)
}
return meta;
}
export function parseWhateverIntoComfyImageLocations(param: any): ComfyImageLocation[] | null {
const meta = parseWhateverIntoImageMetadata(param);
if (!Array.isArray(meta))
return null
return meta.map(m => m.comfyUIFile);
}
export function comfyBoxImageToComfyFile(image: ComfyBoxImageMetadata): ComfyImageLocation {
return image.comfyUIFile
}

View File

@@ -1,9 +1,7 @@
import type { IWidget, LGraphNode } from "@litegraph-js/core";
import ComfyValueControlWidget from "./widgets/ComfyValueControlWidget";
import type { ComfyInputConfig } from "./IComfyInputSlot";
import { LGraphNode, LiteGraph } from "@litegraph-ts/core";
import type IComfyInputSlot from "./IComfyInputSlot";
import { BuiltInSlotShape, LiteGraph } from "@litegraph-ts/core";
import { ComfyComboNode, ComfySliderNode, ComfyTextNode } from "./nodes";
import type { ComfyInputConfig } from "./IComfyInputSlot";
import { ComfyComboNode, ComfyNumberNode, ComfyTextNode } from "./nodes/widgets";
type WidgetFactory = (node: LGraphNode, inputName: string, inputData: any) => IComfyInputSlot;
@@ -37,12 +35,12 @@ function addComfyInput(node: LGraphNode, inputName: string, extraInfo: Partial<I
const FLOAT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {
const config = getNumberDefaults(inputData, 0.5);
return addComfyInput(node, inputName, { type: "number", config, defaultWidgetNode: ComfySliderNode })
return addComfyInput(node, inputName, { type: "number", config, defaultWidgetNode: ComfyNumberNode })
}
const INT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {
const config = getNumberDefaults(inputData, 1);
return addComfyInput(node, inputName, { type: "number", config, defaultWidgetNode: ComfySliderNode })
return addComfyInput(node, inputName, { type: "number", config, defaultWidgetNode: ComfyNumberNode })
};
const STRING: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {

View File

@@ -1,10 +1,9 @@
<script lang="ts">
import type { ComfyButtonNode } from "$lib/nodes/ComfyWidgetNodes";
import type { ComfySliderNode } from "$lib/nodes/index";
import { type WidgetLayout } from "$lib/stores/layoutState";
import { Button } from "@gradio/button";
import { get, type Writable, writable } from "svelte/store";
import { isDisabled } from "./utils"
import type { ComfyButtonNode } from "$lib/nodes/widgets";
export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false;
let node: ComfyButtonNode | null = null;

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import type { ComfyCheckboxNode } from "$lib/nodes/ComfyWidgetNodes";
import { type WidgetLayout } from "$lib/stores/layoutState";
import { Block } from "@gradio/atoms";
import { Checkbox } from "@gradio/form";
import { get, type Writable, writable } from "svelte/store";
import { isDisabled } from "./utils"
import type { SelectData } from "@gradio/utils";
import type { SelectData } from "@gradio/utils";
import type { ComfyCheckboxNode } from "$lib/nodes/widgets";
export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false;

View File

@@ -4,7 +4,7 @@
import Select from 'svelte-select';
// import VirtualList from '$lib/components/VirtualList.svelte';
import VirtualList from 'svelte-tiny-virtual-list';
import type { ComfyComboNode } from "$lib/nodes/index";
import type { ComfyComboNode } from "$lib/nodes/widgets";
import { type WidgetLayout } from "$lib/stores/layoutState";
import { get, writable, type Writable } from "svelte/store";
import { isDisabled } from "./utils"
@@ -13,6 +13,7 @@
let node: ComfyComboNode | null = null;
let nodeValue: Writable<string> | null = null;
let propsChanged: Writable<number> | null = null;
let lightUp: Writable<boolean> = writable(false);
let valuesForCombo: Writable<any[]> | null = null;
let lastConfigured: any = null;
let option: any = null;
@@ -40,6 +41,7 @@
node = widget.node as ComfyComboNode
nodeValue = node.value;
propsChanged = node.propsChanged;
lightUp = node.lightUp;
valuesForCombo = node.valuesForCombo;
lastConfigured = $valuesForCombo
}
@@ -52,16 +54,8 @@
activeIndex = values.findIndex(v => v.value === value);
}
$: $valuesForCombo != lastConfigured && flashOnRefreshed();
let lightUp = false;
function flashOnRefreshed() {
lastConfigured = $valuesForCombo
if (lastConfigured != null) {
lightUp = true;
setTimeout(() => (lightUp = false), 1000);
}
}
$: if ($lightUp)
setTimeout(() => ($lightUp = false), 1000);
function getLinkValue() {
if (!node)
@@ -137,7 +131,7 @@
</script>
<div class="wrapper comfy-combo" class:mobile={isMobile} class:updated={lightUp}>
<div class="wrapper comfy-combo" class:mobile={isMobile} class:updated={$lightUp}>
{#key $valuesForCombo}
{#if node !== null && nodeValue !== null}
{#if $valuesForCombo == null}
@@ -172,10 +166,11 @@
<div class="comfy-select-list" slot="list" let:filteredItems>
{#if filteredItems.length > 0}
{@const itemSize = isMobile ? 50 : 25}
{@const itemsToShow = isMobile ? 10 : 30}
<VirtualList
items={filteredItems}
width="100%"
height={Math.min(filteredItems.length, 10) * itemSize}
height={Math.min(filteredItems.length, itemsToShow) * itemSize}
itemCount={filteredItems.length}
{itemSize}
overscanCount={5}
@@ -252,7 +247,7 @@
--chevron-color: var(--body-text-color);
--border: 1px solid var(--input-border-color);
--border-hover: 1px solid var(--input-border-color-hover);
--border-focused: 1px solid var(--input-border-color-focus);
--border-focused: 1px solid var(--neutral-400);
--border-radius-focused: 0px;
--border-radius: 0px;
--list-background: var(--comfy-dropdown-list-background);

View File

@@ -1,40 +0,0 @@
import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode";
import type { IWidget, LGraphNode, SerializedLGraphNode, Vector2, WidgetCallback, WidgetTypes } from "@litegraph-ts/core";
export default abstract class ComfyWidget<T = any, V = any> implements IWidget<T, V> {
name: string;
value: V;
node: ComfyGraphNode;
constructor(name: string, value: V, node: ComfyGraphNode) {
this.name = name;
this.value = value
this.node = node;
}
isVirtual?: boolean;
options?: T;
type?: WidgetTypes | string | any;
y?: number;
property?: string;
last_y?: number;
width?: number;
clicked?: boolean;
marker?: boolean;
disabled?: boolean;
callback?: WidgetCallback<this>;
setValue(value: V) {
this.value = value;
}
draw?(ctx: CanvasRenderingContext2D, node: LGraphNode, width: number, posY: number, height: number): void;
mouse?(event: MouseEvent, pos: Vector2, node: LGraphNode): boolean;
computeSize?(width: number): [number, number];
afterQueued?(): void;
serializeValue?(serialized: SerializedLGraphNode<LGraphNode>, slot: number): Promise<any>;
}

View File

@@ -6,12 +6,12 @@
import { StaticImage } from "$lib/components/gradio/image";
import type { Styles } from "@gradio/utils";
import type { WidgetLayout } from "$lib/stores/layoutState";
import type { Writable } from "svelte/store";
import type { ComfyGalleryNode } from "$lib/nodes/ComfyWidgetNodes";
import { writable, type Writable } from "svelte/store";
import type { FileData as GradioFileData } from "@gradio/upload";
import type { SelectData as GradioSelectData } from "@gradio/utils";
import { clamp, comfyBoxImageToComfyURL, type ComfyBoxImageMetadata } from "$lib/utils";
import { f7 } from "framework7-svelte";
import type { ComfyGalleryNode } from "$lib/nodes/widgets";
export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false;
@@ -19,8 +19,8 @@
let nodeValue: Writable<ComfyBoxImageMetadata[]> | null = null;
let propsChanged: Writable<number> | null = null;
let option: number | null = null;
let imageWidth: number = 1;
let imageHeight: number = 1;
let imageWidth: Writable<number> = writable(0);
let imageHeight: Writable<number> = writable(0);
let selected_image: number | null = null;
$: widget && setNodeValue(widget);
@@ -30,6 +30,8 @@
node = widget.node as ComfyGalleryNode
nodeValue = node.value;
propsChanged = node.propsChanged;
imageWidth = node.imageWidth
imageHeight = node.imageHeight
if ($nodeValue != null) {
if (node.properties.index < 0 || node.properties.index >= $nodeValue.length) {
@@ -45,18 +47,9 @@
}
let element: HTMLDivElement;
$: if (node) {
if (imageWidth > 1 || imageHeight > 1) {
node.imageSize = [imageWidth, imageHeight]
}
else {
node.imageSize = [1, 1]
}
}
let mobileLightbox = null;
function showMobileLightbox(event: Event) {
function showMobileLightbox(source: HTMLImageElement) {
if (!f7)
return
@@ -65,16 +58,14 @@
mobileLightbox = null;
}
const source = (event.target || event.srcElement) as HTMLImageElement;
const galleryElem = source.closest<HTMLDivElement>("div.block")
console.debug("[ImageViewer] showModal", event, source, galleryElem);
console.debug("[ImageViewer] showModal", source, galleryElem);
if (!galleryElem || ImageViewer.all_gallery_buttons(galleryElem).length === 0) {
console.error("No buttons found on gallery element!", galleryElem)
return;
}
const allGalleryButtons = ImageViewer.all_gallery_buttons(galleryElem);
const selectedSource = source.src
const images = allGalleryButtons.map(button => {
return {
@@ -91,56 +82,29 @@
type: 'popup',
});
mobileLightbox.open(selected_image)
event.stopPropagation()
}
function setupImageForMobileLightbox(e: HTMLImageElement) {
if (e.dataset.modded === "true")
return;
e.dataset.modded = "true";
e.style.cursor = "pointer";
e.style.userSelect = "none";
var isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1
// For Firefox, listening on click first switched to next image then shows the lightbox.
// If you know how to fix this without switching to mousedown event, please.
// For other browsers the event is click to make it possiblr to drag picture.
var event = isFirefox ? 'mousedown' : 'click'
e.addEventListener(event, (evt) => {
evt.preventDefault()
showMobileLightbox(evt)
}, true);
function onClicked(e: CustomEvent<HTMLImageElement>) {
if (isMobile) {
showMobileLightbox(e.detail)
}
else {
ImageViewer.instance.showLightbox(e.detail)
}
}
function onSelect(e: CustomEvent<GradioSelectData>) {
// Setup lightbox
// Wait for gradio gallery to show the large preview image, if no timeout then
// the event might fire too early
const callback = isMobile ? setupImageForMobileLightbox
: ImageViewer.instance.setupGalleryImageForLightbox.bind(ImageViewer.instance)
setTimeout(() => {
const images = element.querySelectorAll<HTMLImageElement>('div.block div > img')
if (images != null) {
images.forEach(callback);
}
ImageViewer.instance.refreshImages();
}, 200)
// Update index
node.setProperty("index", e.detail.index as number)
}
$: if ($propsChanged > -1 && widget && $nodeValue) {
if (widget.attrs.variant === "image") {
selected_image = $nodeValue.length - 1
node.setProperty("index", selected_image)
}
else {
node.setProperty("index", $nodeValue.length > 0 ? 0 : null)
}
}
else {
node.setProperty("index", null)
@@ -160,8 +124,8 @@
value={url}
show_label={widget.attrs.title != ""}
label={widget.attrs.title}
bind:imageWidth
bind:imageHeight
bind:imageWidth={$imageWidth}
bind:imageHeight={$imageHeight}
/>
{:else}
<Empty size="large" unpadded_box={true}><Image /></Empty>
@@ -181,8 +145,9 @@
root={""}
root_url={""}
on:select={onSelect}
bind:imageWidth
bind:imageHeight
on:clicked={onClicked}
bind:imageWidth={$imageWidth}
bind:imageHeight={$imageHeight}
bind:selected_image
/>
</div>

View File

@@ -1,343 +0,0 @@
<script lang="ts">
import { type WidgetLayout } from "$lib/stores/layoutState";
import { Block } from "@gradio/atoms";
import { TextBox } from "@gradio/form";
import Row from "$lib/components/gradio/app/Row.svelte";
import { get, type Writable } from "svelte/store";
import Modal from "$lib/components/Modal.svelte";
import { Button } from "@gradio/button";
import type { ComfyImageEditorNode, ComfyImageLocation } from "$lib/nodes/ComfyWidgetNodes";
import { Embed as Klecks } from "klecks";
import "klecks/style/style.scss";
import ImageUpload from "$lib/components/ImageUpload.svelte";
import { uploadImageToComfyUI, type ComfyBoxImageMetadata, comfyFileToComfyBoxMetadata, comfyBoxImageToComfyURL, comfyBoxImageToComfyFile, type ComfyUploadImageType } from "$lib/utils";
import notify from "$lib/notify";
import NumberInput from "$lib/components/NumberInput.svelte";
export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false;
let node: ComfyImageEditorNode | null = null;
let nodeValue: Writable<ComfyBoxImageMetadata[]> | null = null;
let attrsChanged: Writable<number> | null = null;
let imgWidth: number = 0;
let imgHeight: number = 0;
$: widget && setNodeValue(widget);
$: if ($nodeValue && $nodeValue.length > 0) {
// TODO improve
if (imgWidth > 0 && imgHeight > 0) {
$nodeValue[0].width = imgWidth
$nodeValue[0].height = imgHeight
}
else {
$nodeValue[0].width = 0
$nodeValue[0].height = 0
}
}
function setNodeValue(widget: WidgetLayout) {
if (widget) {
node = widget.node as ComfyImageEditorNode
nodeValue = node.value;
attrsChanged = widget.attrsChanged;
status = $nodeValue && $nodeValue.length > 0 ? "uploaded" : "empty"
}
};
let editorRoot: HTMLDivElement | null = null;
let showModal = false;
let kl: Klecks | null = null;
function disposeEditor() {
console.warn("[ImageEditorWidget] CLOSING", widget, $nodeValue)
if (editorRoot) {
while (editorRoot.firstChild) {
editorRoot.removeChild(editorRoot.firstChild);
}
}
kl = null;
showModal = false;
}
function generateBlankCanvas(width: number, height: number, fill: string = "#fff"): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.save();
ctx.fillStyle = fill,
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
return canvas;
}
async function loadImage(imageURL: string): Promise<HTMLImageElement> {
return new Promise((resolve) => {
const e = new Image();
e.setAttribute('crossorigin', 'anonymous'); // Don't taint the canvas from loading files on-disk
e.addEventListener("load", () => { resolve(e); });
e.src = imageURL;
return e;
});
}
async function generateImageCanvas(imageURL: string): Promise<[HTMLCanvasElement, number, number]> {
const image = await loadImage(imageURL);
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d');
ctx.save();
ctx.fillStyle = "rgba(255, 255, 255, 0.0)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0);
ctx.restore();
return [canvas, image.width, image.height];
}
const FILENAME: string = "ComfyUITemp.png";
const SUBFOLDER: string = "ComfyBox_Editor";
const DIRECTORY: ComfyUploadImageType = "input";
async function submitKlecksToComfyUI(onSuccess: () => void, onError: () => void) {
const blob = kl.getPNG();
status = "uploading"
await uploadImageToComfyUI(blob, FILENAME, DIRECTORY, SUBFOLDER)
.then((entry: ComfyImageLocation) => {
const meta: ComfyBoxImageMetadata = comfyFileToComfyBoxMetadata(entry);
$nodeValue = [meta] // TODO more than one image
status = "uploaded"
notify("Saved image to ComfyUI!", { type: "success" })
onSuccess();
})
.catch(err => {
notify(`Failed to upload image from editor: ${err}`, { type: "error", timeout: 10000 })
status = "error"
uploadError = err;
$nodeValue = []
onError();
})
}
let closeDialog = null;
async function saveAndClose() {
console.log(closeDialog, kl)
if (!closeDialog || !kl)
return;
submitKlecksToComfyUI(() => {}, () => {});
closeDialog()
}
let blankImageWidth = 512;
let blankImageHeight = 512;
async function openImageEditor() {
if (!editorRoot)
return;
showModal = true;
const url = `http://${location.hostname}:8188` // TODO make configurable
kl = new Klecks({
embedUrl: url,
onSubmit: submitKlecksToComfyUI,
targetEl: editorRoot,
warnOnPageClose: false
});
console.warn("[ImageEditorWidget] OPENING", widget, $nodeValue)
let canvas = null;
let width = blankImageWidth;
let height = blankImageHeight;
if ($nodeValue && $nodeValue.length > 0) {
const comfyImage = $nodeValue[0];
const comfyURL = comfyBoxImageToComfyURL(comfyImage);
[canvas, width, height] = await generateImageCanvas(comfyURL);
}
else {
canvas = generateBlankCanvas(width, height);
}
kl.openProject({
width: width,
height: height,
layers: [{
name: 'Image',
opacity: 1,
mixModeStr: 'source-over',
image: canvas
}]
});
setTimeout(function () {
kl.klApp?.out("yo");
}, 1000);
}
let status = "empty";
let uploadError = null;
function onUploading() {
console.warn("UPLOADING!!!")
uploadError = null;
status = "uploading"
}
function onUploaded(e: CustomEvent<ComfyImageLocation[]>) {
console.warn("UPLOADED!!!")
uploadError = null;
status = "uploaded"
$nodeValue = e.detail.map(comfyFileToComfyBoxMetadata);
}
function onClear() {
console.warn("CLEAR!!!")
uploadError = null;
status = "empty"
$nodeValue = []
}
function onUploadError(e: CustomEvent<any>) {
console.warn("ERROR!!!")
status = "error"
uploadError = e.detail
$nodeValue = []
notify(`Failed to upload image to ComfyUI: ${uploadError}`, { type: "error", timeout: 10000 })
}
function onChange(e: CustomEvent<ComfyImageLocation[]>) {
}
let _value: ComfyImageLocation[] = []
$: if ($nodeValue)
_value = $nodeValue.map(comfyBoxImageToComfyFile)
else
_value = []
$: canEdit = status === "empty" || status === "uploaded";
</script>
<div class="wrapper comfy-image-editor">
{#if widget.attrs.variant === "fileUpload" || isMobile}
<ImageUpload value={_value}
bind:imgWidth
bind:imgHeight
fileCount={"single"}
elem_classes={[]}
style={""}
label={widget.attrs.title}
on:uploading={onUploading}
on:uploaded={onUploaded}
on:upload_error={onUploadError}
on:clear={onClear}
on:change={onChange}
on:image_clicked={openImageEditor}
/>
{:else}
<div class="comfy-image-editor-panel">
<ImageUpload value={_value}
bind:imgWidth
bind:imgHeight
fileCount={"single"}
elem_classes={[]}
style={""}
label={widget.attrs.title}
on:uploading={onUploading}
on:uploaded={onUploaded}
on:upload_error={onUploadError}
on:clear={onClear}
on:change={onChange}
on:image_clicked={openImageEditor}
/>
<Modal bind:showModal closeOnClick={false} on:close={disposeEditor} bind:closeDialog>
<div>
<div id="klecks-loading-screen">
<span id="klecks-loading-screen-text"></span>
</div>
<div class="image-editor-root" bind:this={editorRoot} />
</div>
<div slot="buttons">
<Button variant="primary" on:click={saveAndClose}>
Save and Close
</Button>
<Button variant="secondary" on:click={closeDialog}>
Discard Edits
</Button>
</div>
</Modal>
<Block>
{#if !$nodeValue || $nodeValue.length === 0}
<Row>
<Row>
<Button variant="secondary" disabled={!canEdit} on:click={openImageEditor}>
Create Image
</Button>
<div>
<TextBox show_label={false} disabled={true} value="Status: {status}"/>
</div>
{#if uploadError}
<div>
Upload error: {uploadError}
</div>
{/if}
</Row>
<Row>
<NumberInput label={"Width"} min={64} max={2048} step={64} bind:value={blankImageWidth} />
<NumberInput label={"Height"} min={64} max={2048} step={64} bind:value={blankImageHeight} />
</Row>
</Row>
{:else}
<Row>
<Button variant="secondary" disabled={!canEdit} on:click={openImageEditor}>
Edit Image
</Button>
<div>
<TextBox show_label={false} disabled={true} value="Status: {status}"/>
</div>
{#if uploadError}
<div>
Upload error: {uploadError}
</div>
{/if}
</Row>
{/if}
</Block>
</div>
{/if}
</div>
<style lang="scss">
.image-editor-root {
width: 75vw;
height: 75vh;
overflow: hidden;
color: black;
:global(> .g-root) {
height: calc(100% - 59px);
}
}
.comfy-image-editor {
:global(> dialog) {
overflow: hidden;
}
}
:global(.kl-popup) {
z-index: 999999999999;
}
</style>

View File

@@ -0,0 +1,333 @@
<script lang="ts">
import { type WidgetLayout } from "$lib/stores/layoutState";
import { Block } from "@gradio/atoms";
import { TextBox } from "@gradio/form";
import Row from "$lib/components/gradio/app/Row.svelte";
import { get, writable, type Writable } from "svelte/store";
import Modal from "$lib/components/Modal.svelte";
import { Button } from "@gradio/button";
import { Embed as Klecks } from "klecks";
import "klecks/style/style.scss";
import ImageUpload from "$lib/components/ImageUpload.svelte";
import { uploadImageToComfyUI, type ComfyBoxImageMetadata, comfyFileToComfyBoxMetadata, comfyBoxImageToComfyURL, comfyBoxImageToComfyFile, type ComfyUploadImageType, type ComfyImageLocation } from "$lib/utils";
import notify from "$lib/notify";
import NumberInput from "$lib/components/NumberInput.svelte";
import type { ComfyImageEditorNode } from "$lib/nodes/widgets";
export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false;
let node: ComfyImageEditorNode | null = null;
let nodeValue: Writable<ComfyBoxImageMetadata[]> | null = null;
let attrsChanged: Writable<number> | null = null;
let imgWidth: Writable<number> = writable(0);
let imgHeight: Writable<number> = writable(0);
$: widget && setNodeValue(widget);
function setNodeValue(widget: WidgetLayout) {
if (widget) {
node = widget.node as ComfyImageEditorNode
nodeValue = node.value;
attrsChanged = widget.attrsChanged;
imgWidth = node.imgWidth
imgHeight = node.imgHeight
status = $nodeValue && $nodeValue.length > 0 ? "uploaded" : "empty"
}
};
let editorRoot: HTMLDivElement | null = null;
let showModal = false;
let kl: Klecks | null = null;
function disposeEditor() {
console.warn("[ImageEditorWidget] CLOSING", widget, $nodeValue)
if (editorRoot) {
while (editorRoot.firstChild) {
editorRoot.removeChild(editorRoot.firstChild);
}
}
kl = null;
showModal = false;
}
function generateBlankCanvas(width: number, height: number, fill: string = "#fff"): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.save();
ctx.fillStyle = fill,
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
return canvas;
}
async function loadImage(imageURL: string): Promise<HTMLImageElement> {
return new Promise((resolve) => {
const e = new Image();
e.setAttribute('crossorigin', 'anonymous'); // Don't taint the canvas from loading files on-disk
e.addEventListener("load", () => { resolve(e); });
e.src = imageURL;
return e;
});
}
async function generateImageCanvas(imageURL: string): Promise<[HTMLCanvasElement, number, number]> {
const image = await loadImage(imageURL);
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d');
ctx.save();
ctx.fillStyle = "rgba(255, 255, 255, 0.0)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0);
ctx.restore();
return [canvas, image.width, image.height];
}
const FILENAME: string = "ComfyUITemp.png";
const SUBFOLDER: string = "ComfyBox_Editor";
const DIRECTORY: ComfyUploadImageType = "input";
async function submitKlecksToComfyUI(onSuccess: () => void, onError: () => void) {
const blob = kl.getPNG();
status = "uploading"
await uploadImageToComfyUI(blob, FILENAME, DIRECTORY, SUBFOLDER)
.then((entry: ComfyImageLocation) => {
const meta: ComfyBoxImageMetadata = comfyFileToComfyBoxMetadata(entry);
$nodeValue = [meta] // TODO more than one image
status = "uploaded"
notify("Saved image to ComfyUI!", { type: "success" })
onSuccess();
})
.catch(err => {
notify(`Failed to upload image from editor: ${err}`, { type: "error", timeout: 10000 })
status = "error"
uploadError = err;
$nodeValue = []
onError();
})
}
let closeDialog = null;
async function saveAndClose() {
console.log(closeDialog, kl)
if (!closeDialog || !kl)
return;
submitKlecksToComfyUI(() => {}, () => {});
closeDialog()
}
let blankImageWidth = 512;
let blankImageHeight = 512;
async function openImageEditor() {
if (!editorRoot)
return;
showModal = true;
const url = `http://${location.hostname}:8188` // TODO make configurable
kl = new Klecks({
embedUrl: url,
onSubmit: submitKlecksToComfyUI,
targetEl: editorRoot,
warnOnPageClose: false
});
console.warn("[ImageEditorWidget] OPENING", widget, $nodeValue)
let canvas = null;
let width = blankImageWidth;
let height = blankImageHeight;
if ($nodeValue && $nodeValue.length > 0) {
const comfyImage = $nodeValue[0];
const comfyURL = comfyBoxImageToComfyURL(comfyImage);
[canvas, width, height] = await generateImageCanvas(comfyURL);
}
else {
canvas = generateBlankCanvas(width, height);
}
kl.openProject({
width: width,
height: height,
layers: [{
name: 'Image',
opacity: 1,
mixModeStr: 'source-over',
image: canvas
}]
});
setTimeout(function () {
kl?.klApp?.out("yo");
}, 1000);
}
let status = "empty";
let uploadError = null;
function onUploading() {
console.warn("UPLOADING!!!")
uploadError = null;
status = "uploading"
}
function onUploaded(e: CustomEvent<ComfyImageLocation[]>) {
console.warn("UPLOADED!!!")
uploadError = null;
status = "uploaded"
$nodeValue = e.detail.map(comfyFileToComfyBoxMetadata);
}
function onClear() {
console.warn("CLEAR!!!")
uploadError = null;
status = "empty"
$nodeValue = []
}
function onUploadError(e: CustomEvent<any>) {
console.warn("ERROR!!!")
status = "error"
uploadError = e.detail
$nodeValue = []
notify(`Failed to upload image to ComfyUI: ${uploadError}`, { type: "error", timeout: 10000 })
}
function onChange(e: CustomEvent<ComfyImageLocation[]>) {
}
let _value: ComfyImageLocation[] = []
$: if ($nodeValue)
_value = $nodeValue.map(comfyBoxImageToComfyFile)
else
_value = []
$: canEdit = status === "empty" || status === "uploaded";
</script>
<div class="wrapper comfy-image-editor">
{#if widget.attrs.variant === "fileUpload" || isMobile}
<ImageUpload value={_value}
bind:imgWidth={$imgWidth}
bind:imgHeight={$imgHeight}
fileCount={"single"}
elem_classes={[]}
style={""}
label={widget.attrs.title}
on:uploading={onUploading}
on:uploaded={onUploaded}
on:upload_error={onUploadError}
on:clear={onClear}
on:change={onChange}
on:image_clicked={openImageEditor}
/>
{:else}
<div class="comfy-image-editor-panel">
<ImageUpload value={_value}
bind:imgWidth={$imgWidth}
bind:imgHeight={$imgHeight}
fileCount={"single"}
elem_classes={[]}
style={""}
label={widget.attrs.title}
on:uploading={onUploading}
on:uploaded={onUploaded}
on:upload_error={onUploadError}
on:clear={onClear}
on:change={onChange}
on:image_clicked={openImageEditor}
/>
<Modal bind:showModal closeOnClick={false} on:close={disposeEditor} bind:closeDialog>
<div>
<div id="klecks-loading-screen">
<span id="klecks-loading-screen-text"></span>
</div>
<div class="image-editor-root" bind:this={editorRoot} />
</div>
<div slot="buttons">
<Button variant="primary" on:click={saveAndClose}>
Save and Close
</Button>
<Button variant="secondary" on:click={closeDialog}>
Discard Edits
</Button>
</div>
</Modal>
<Block>
{#if !$nodeValue || $nodeValue.length === 0}
<Row>
<Row>
<Button variant="secondary" disabled={!canEdit} on:click={openImageEditor}>
Create Image
</Button>
<div>
<TextBox show_label={false} disabled={true} value="Status: {status}"/>
</div>
{#if uploadError}
<div>
Upload error: {uploadError}
</div>
{/if}
</Row>
<Row>
<NumberInput label={"Width"} min={64} max={2048} step={64} bind:value={blankImageWidth} />
<NumberInput label={"Height"} min={64} max={2048} step={64} bind:value={blankImageHeight} />
</Row>
</Row>
{:else}
<Row>
<Button variant="secondary" disabled={!canEdit} on:click={openImageEditor}>
Edit Image
</Button>
<div>
<TextBox label={""} show_label={false} disabled={true} lines={1} max_lines={1} value="Status: {status}"/>
</div>
{#if uploadError}
<div>
Upload error: {uploadError}
</div>
{/if}
</Row>
{/if}
</Block>
</div>
{/if}
</div>
<style lang="scss">
.image-editor-root {
width: 75vw;
height: 75vh;
overflow: hidden;
color: black;
:global(> .g-root) {
height: calc(100% - 59px);
}
}
.comfy-image-editor {
:global(> dialog) {
overflow: hidden;
}
}
:global(.kl-popup) {
z-index: 999999999999;
}
</style>

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import type { ComfySliderNode } from "$lib/nodes/index";
import type { ComfyNumberNode } from "$lib/nodes/widgets";
import { type WidgetLayout } from "$lib/stores/layoutState";
import { Range } from "$lib/components/gradio/form";
import { get, type Writable } from "svelte/store";
import { debounce } from "$lib/utils";
import interfaceState from "$lib/stores/interfaceState";
import { debounce } from "$lib/utils";
import interfaceState from "$lib/stores/interfaceState";
import { isDisabled } from "./utils"
export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false;
let node: ComfySliderNode | null = null;
let node: ComfyNumberNode | null = null;
let nodeValue: Writable<number> | null = null;
let propsChanged: Writable<number> | null = null;
let option: number | null = null;
@@ -18,7 +18,7 @@
function setNodeValue(widget: WidgetLayout) {
if (widget) {
node = widget.node as ComfySliderNode
node = widget.node as ComfyNumberNode
nodeValue = node.value;
propsChanged = node.propsChanged;
setOption($nodeValue); // don't react on option

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import type { ComfyRadioNode } from "$lib/nodes/ComfyWidgetNodes";
import { type WidgetLayout } from "$lib/stores/layoutState";
import { Block } from "@gradio/atoms";
import { Radio } from "@gradio/form";
@@ -7,6 +6,7 @@
import { isDisabled } from "./utils"
import type { SelectData } from "@gradio/utils";
import { clamp } from "$lib/utils";
import type { ComfyRadioNode } from "$lib/nodes/widgets";
export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false;

View File

@@ -1,21 +1,21 @@
<script lang="ts">
import { TextBox } from "@gradio/form";
import type { ComfyComboNode } from "$lib/nodes/index";
import { type WidgetLayout } from "$lib/stores/layoutState";
import { get, type Writable } from "svelte/store";
import { type Writable } from "svelte/store";
import { isDisabled } from "./utils"
import type { ComfyTextNode } from "$lib/nodes/widgets";
export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false;
let node: ComfyComboNode | null = null;
let node: ComfyTextNode | null = null;
let nodeValue: Writable<string> | null = null;
let propsChanged: Writable<number> | null = null;
let itemValue: WidgetUIStateStore | null = null;
$: widget && setNodeValue(widget);
function setNodeValue(widget: WidgetLayout) {
if (widget) {
node = widget.node as ComfySliderNode
node = widget.node as ComfyTextNode
nodeValue = node.value;
propsChanged = node.propsChanged;
}

View File

@@ -1,2 +0,0 @@
export { default as ComfyGalleryWidget } from "./ComfyGalleryWidget"
export { default as ComfyGalleryWidget_Svelte } from "./ComfyGalleryWidget.svelte"

View File

@@ -1,14 +1,27 @@
import type { IDragItem } from "$lib/stores/layoutState";
import layoutState from "$lib/stores/layoutState";
import { NodeMode } from "@litegraph-ts/core";
import { LGraphNode, NodeMode } from "@litegraph-ts/core";
import { get } from "svelte/store";
export function isNodeDisabled(node: LGraphNode): boolean {
while (node != null) {
if (node.mode !== NodeMode.ALWAYS) {
return true;
}
if (node.graph == null) {
return true
}
node = node.graph._subgraph_node;
}
return false;
}
export function isDisabled(widget: IDragItem) {
if (widget.attrs.disabled)
return true;
if (widget.type === "widget") {
return widget.attrs.nodeDisabledState === "disabled" && widget.node.mode === NodeMode.NEVER
return widget.attrs.nodeDisabledState === "disabled" && isNodeDisabled(widget.node)
}
return false;
@@ -19,7 +32,7 @@ export function isHidden(widget: IDragItem) {
return true;
if (widget.type === "widget") {
return widget.attrs.nodeDisabledState === "hidden" && widget.node.mode === NodeMode.NEVER
return widget.attrs.nodeDisabledState === "hidden" && isNodeDisabled(widget.node)
}
return false;

View File

@@ -1,10 +1,15 @@
import { LiteGraph } from '@litegraph-ts/core';
import ComfyApp from '$lib/components/ComfyApp';
import { configureLitegraph } from '$lib/init';
import App from './App.svelte';
LiteGraph.use_uuids = true;
configureLitegraph()
const comfyApp = new ComfyApp();
(window as any).app = comfyApp;
const app = new App({
target: document.getElementById('app'),
target: document.getElementById("app-root"),
props: { app: comfyApp }
})
export default app;

View File

@@ -6,20 +6,17 @@ import ComfyApp from '$lib/components/ComfyApp';
import uiState from '$lib/stores/uiState';
import { LiteGraph } from '@litegraph-ts/core';
import ComfyGraph from '$lib/ComfyGraph';
import { configureLitegraph } from '$lib/init';
Framework7.use(Framework7Svelte);
LiteGraph.use_uuids = true;
LiteGraph.dialog_close_on_mouse_leave = false;
LiteGraph.search_hide_on_mouse_leave = false;
LiteGraph.pointerevents_method = "pointer";
configureLitegraph(true);
const comfyApp = new ComfyApp();
uiState.update(s => { s.app = comfyApp; return s; })
(window as any).app = comfyApp;
const app = new AppMobile({
target: document.getElementById('app'),
target: document.getElementById("app-root"),
props: { app: comfyApp }
})

View File

@@ -1,4 +1,5 @@
@import "gradio";
@import "litegraph";
body {
overflow: hidden;
@@ -7,14 +8,22 @@ body {
overscroll-behavior-y: contain;
}
#app {
#app-root {
background: var(--body-background-fill);
}
:root {
--color-blue-500: #3985f5;
--input-border-color-focus: var(--neutral-400);
--comfy-accent-soft: var(--neutral-300);
--comfy-widget-selected-background-fill: var(--color-yellow-100);
--comfy-widget-hovered-background-fill: var(--secondary-200);
--comfy-container-selected-background-fill: var(--color-yellow-300);
--comfy-container-hovered-background-fill: var(--secondary-300);
--comfy-container-empty-background-fill: var(--color-grey-300);
--comfy-container-empty-border-color: var(--color-grey-400);
--comfy-disabled-label-color: var(--neutral-400);
--comfy-disabled-textbox-background-fill: var(--neutral-200);
--comfy-disabled-textbox-border-color: var(--neutral-300);
@@ -37,7 +46,15 @@ body {
.dark {
color-scheme: dark;
--input-border-color-focus: var(--neutral-500);
--comfy-accent-soft: var(--neutral-600);
--comfy-widget-selected-background-fill: var(--primary-500);
--comfy-widget-hovered-background-fill: var(--neutral-600);
--comfy-container-selected-background-fill: var(--primary-700);
--comfy-container-hovered-background-fill: var(--neutral-500);
--comfy-container-empty-background-fill: var(--color-grey-800);
--comfy-container-empty-border-color: var(--color-grey-600);
--comfy-disabled-label-color: var(--neutral-500);
--comfy-disabled-textbox-background-fill: var(--neutral-800);
--comfy-disabled-textbox-border-color: var(--neutral-700);
@@ -90,9 +107,16 @@ hr {
color: var(--panel-border-color);
}
select {
color: var(--body-text-color);
background: var(--block-background-fill);
input, textarea {
border-radius: 0 !important;
}
:not(.litegraph) {
select {
color: var(--body-text-color);
background: var(--input-background-fill);
border: var(--input-border-width) solid var(--input-border-color)
}
}
.container {
@@ -125,17 +149,6 @@ select {
// }
}
// button {
// filter: none;
// &.primary:active {
// filter: brightness(80%)
// }
// &.secondary:active {
// filter: brightness(80%)
// }
// }
button {
&.primary:active {
border-color: var(--button-primary-border-color-active) !important;

108
src/scss/litegraph.scss Normal file
View File

@@ -0,0 +1,108 @@
// Improvements to litegraph's css.
// Taken from ComfyUI.
:root {
--litegraph-fg-color: #000;
--litegraph-bg-color: #fff;
--litegraph-comfy-menu-bg: #353535;
--litegraph-comfy-input-bg: #222;
--litegraph-input-text: #ddd;
--litegraph-descrip-text: #999;
--litegraph-drag-text: #ccc;
--litegraph-error-text: #ff4444;
--litegraph-border-color: #4e4e4e;
}
.litegraph {
/* Input popup */
&.graphdialog {
min-height: 1em;
background-color: var(--litegraph-comfy-menu-bg);
.name {
font-size: 14px;
font-family: sans-serif;
color: var(--litegraph-descrip-text);
}
button {
margin-top: unset;
vertical-align: unset;
height: 1.6em;
padding-right: 8px;
}
}
.graphdialog input, .graphdialog textarea, .graphdialog select {
background-color: var(--litegraph-comfy-input-bg);
border: 2px solid;
border-color: var(--litegraph-border-color);
color: var(--litegraph-input-text);
border-radius: 12px 0 0 12px;
}
/* Context Menu */
.litemenu-entry {
&.has_submenu {
position: relative;
padding-right: 20px;
}
}
&.litecontextmenu {
.litemenu-entry {
&:hover {
&:not(.disabled):not(.separator) {
background-color: var(--litegraph-comfy-menu-bg) !important;
filter: brightness(155%);
color: var(--litegraph-input-text);
}
}
}
input {
background-color: var(--litegraph-comfy-input-bg) !important;
color: var(--litegraph-input-text) !important;
}
}
&.litesearchbox {
z-index: 9999 !important;
background-color: var(--litegraph-comfy-menu-bg) !important;
overflow: hidden;
}
&.lite-search-item {
color: var(--litegraph-input-text);
background-color: var(--litegraph-comfy-input-bg);
filter: brightness(80%);
padding-left: 0.2em;
&.generic_type {
color: var(--litegraph-input-text);
filter: brightness(50%);
}
}
}
.litemenu-entry {
&.has_submenu {
&::after {
content: ">";
position: absolute;
top: 0;
right: 2px;
}
}
}
.litegraph.litecontextmenu,
.litegraph.litecontextmenu.dark {
z-index: 9999 !important;
background-color: var(--litegraph-comfy-menu-bg) !important;
filter: brightness(95%);
}
.litegraph.litecontextmenu .litemenu-entry.submenu,
.litegraph.litecontextmenu.dark .litemenu-entry.submenu {
background-color: var(--litegraph-comfy-menu-bg) !important;
color: var(--litegraph-input-text);
}
.litegraph.litesearchbox input,
.litegraph.litesearchbox select {
background-color: var(--litegraph-comfy-input-bg) !important;
color: var(--litegraph-input-text);
}

View File

@@ -0,0 +1,94 @@
import { LGraph, LiteGraph, Subgraph, type SlotLayout } from "@litegraph-ts/core"
import { Watch } from "@litegraph-ts/nodes-basic"
import { expect } from 'vitest'
import UnitTest from "./UnitTest"
import ComfyGraph from "$lib/ComfyGraph";
import ComfyPromptSerializer from "$lib/components/ComfyPromptSerializer";
import { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode";
import ComfyGraphNode from "$lib/nodes/ComfyGraphNode";
import { graphToGraphVis } from "$lib/utils";
import layoutState from "$lib/stores/layoutState";
import { ComfyNumberNode } from "$lib/nodes/widgets";
import { get } from "svelte/store";
export default class ComfyGraphTests extends UnitTest {
test__onNodeAdded__updatesLayoutState() {
const graph = new ComfyGraph();
layoutState.initDefaultLayout() // adds 3 containers
const state = get(layoutState)
expect(Object.keys(state.allItems)).toHaveLength(3)
expect(Object.keys(state.allItemsByNode)).toHaveLength(0)
const widget = LiteGraph.createNode(ComfyNumberNode);
graph.add(widget)
expect(Object.keys(state.allItems)).toHaveLength(4)
expect(Object.keys(state.allItemsByNode)).toHaveLength(1)
expect(state.allItemsByNode[widget.id]).toBeTruthy();
graph.add(widget)
}
test__correctSubgraphFactory() {
const graph = new ComfyGraph();
const subgraph = LiteGraph.createNode(Subgraph);
graph.add(subgraph)
expect(subgraph.graph).toBeInstanceOf(ComfyGraph)
}
test__onNodeAdded__handlesNodesAddedInSubgraphs() {
const graph = new ComfyGraph();
layoutState.initDefaultLayout()
const subgraph = LiteGraph.createNode(Subgraph);
graph.add(subgraph)
const state = get(layoutState)
expect(Object.keys(state.allItems)).toHaveLength(3)
expect(Object.keys(state.allItemsByNode)).toHaveLength(0)
const widget = LiteGraph.createNode(ComfyNumberNode);
subgraph.subgraph.add(widget)
expect(Object.keys(state.allItems)).toHaveLength(4)
expect(Object.keys(state.allItemsByNode)).toHaveLength(1)
expect(state.allItemsByNode[widget.id]).toBeTruthy();
}
test__onNodeAdded__handlesSubgraphsWithNodes() {
const graph = new ComfyGraph();
layoutState.initDefaultLayout()
const state = get(layoutState)
expect(Object.keys(state.allItems)).toHaveLength(3)
expect(Object.keys(state.allItemsByNode)).toHaveLength(0)
const subgraph = LiteGraph.createNode(Subgraph);
const widget = LiteGraph.createNode(ComfyNumberNode);
subgraph.subgraph.add(widget)
graph.add(subgraph)
expect(Object.keys(state.allItems)).toHaveLength(4)
expect(Object.keys(state.allItemsByNode)).toHaveLength(1)
expect(state.allItemsByNode[widget.id]).toBeTruthy();
}
test__onNodeRemoved__updatesLayoutState() {
const graph = new ComfyGraph();
layoutState.initDefaultLayout()
const widget = LiteGraph.createNode(ComfyNumberNode);
graph.add(widget)
const state = get(layoutState)
expect(Object.keys(state.allItems)).toHaveLength(4)
expect(Object.keys(state.allItemsByNode)).toHaveLength(1)
expect(state.allItemsByNode[widget.id]).toBeTruthy();
graph.remove(widget)
expect(Object.keys(state.allItems)).toHaveLength(3)
expect(Object.keys(state.allItemsByNode)).toHaveLength(0)
}
}

View File

@@ -0,0 +1,181 @@
import { LGraph, LiteGraph, Subgraph, type SlotLayout } from "@litegraph-ts/core"
import { Watch } from "@litegraph-ts/nodes-basic"
import { expect } from 'vitest'
import UnitTest from "./UnitTest"
import ComfyGraph from "$lib/ComfyGraph";
import ComfyPromptSerializer from "$lib/components/ComfyPromptSerializer";
import { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode";
import ComfyGraphNode from "$lib/nodes/ComfyGraphNode";
import { graphToGraphVis } from "$lib/utils";
class MockBackendInput extends ComfyGraphNode {
override isBackendNode = true;
comfyClass: string = "MockBackendInput";
static slotLayout: SlotLayout = {
inputs: [
{ name: "in", type: "*" },
],
}
}
LiteGraph.registerNodeType({
class: MockBackendInput,
title: "Test.MockBackendInput",
desc: "one input",
type: "test/input"
})
class MockBackendLink extends ComfyGraphNode {
override isBackendNode = true;
comfyClass: string = "MockBackendLink";
static slotLayout: SlotLayout = {
inputs: [
{ name: "in", type: "*" },
],
outputs: [
{ name: "out", type: "*" },
],
}
}
LiteGraph.registerNodeType({
class: MockBackendLink,
title: "Test.MockBackendLink",
desc: "one input, one output",
type: "test/link"
})
class MockBackendOutput extends ComfyGraphNode {
override isBackendNode = true;
comfyClass: string = "MockBackendOutput";
static slotLayout: SlotLayout = {
outputs: [
{ name: "out", type: "*" },
],
}
}
LiteGraph.registerNodeType({
class: MockBackendOutput,
title: "Test.MockBackendOutput",
desc: "one output",
type: "test/output"
})
export default class ComfyPromptSerializerTests extends UnitTest {
test__serialize__shouldIgnoreFrontend() {
const ser = new ComfyPromptSerializer();
const graph = new ComfyGraph();
const nodeA = LiteGraph.createNode(Watch)
const nodeB = LiteGraph.createNode(Watch)
graph.add(nodeA)
graph.add(nodeB)
const result = ser.serialize(graph)
expect(result.output).toEqual({})
}
test__serialize__shouldSerializeBackendNodes() {
const ser = new ComfyPromptSerializer();
const graph = new ComfyGraph();
const input = LiteGraph.createNode(MockBackendInput)
const link = LiteGraph.createNode(MockBackendLink)
const output = LiteGraph.createNode(MockBackendOutput)
graph.add(input)
graph.add(link)
graph.add(output)
output.connect(0, link, 0)
link.connect(0, input, 0)
const result = ser.serialize(graph)
console.warn(result.output)
expect(Object.keys(result.output)).toHaveLength(3);
expect(result.output[input.id].inputs["in"]).toBeInstanceOf(Array)
expect(result.output[input.id].inputs["in"][0]).toEqual(link.id)
expect(result.output[link.id].inputs["in"]).toBeInstanceOf(Array)
expect(result.output[link.id].inputs["in"][0]).toEqual(output.id)
expect(Object.keys(result.output[output.id].inputs)).toHaveLength(0);
}
test__serialize__shouldFollowSubgraphs() {
const ser = new ComfyPromptSerializer();
const graph = new ComfyGraph();
const output = LiteGraph.createNode(MockBackendOutput)
const link = LiteGraph.createNode(MockBackendLink)
const input = LiteGraph.createNode(MockBackendInput)
const subgraph = LiteGraph.createNode(Subgraph)
const graphInput = subgraph.addGraphInput("testIn", "number")
const graphOutput = subgraph.addGraphOutput("testOut", "number")
graph.add(subgraph)
graph.add(output)
subgraph.subgraph.add(link)
graph.add(input)
output.connect(0, subgraph, 0)
graphInput.innerNode.connect(0, link, 0)
link.connect(0, graphOutput.innerNode, 0)
subgraph.connect(0, input, 0)
const result = ser.serialize(graph)
expect(Object.keys(result.output)).toHaveLength(3);
expect(result.output[input.id].inputs["in"]).toBeInstanceOf(Array)
expect(result.output[input.id].inputs["in"][0]).toEqual(link.id)
expect(result.output[link.id].inputs["in"]).toBeInstanceOf(Array)
expect(result.output[link.id].inputs["in"][0]).toEqual(output.id)
expect(result.output[output.id].inputs).toEqual({})
}
test__serialize__shouldFollowSubgraphsRecursively() {
const ser = new ComfyPromptSerializer();
const graph = new ComfyGraph();
const output = LiteGraph.createNode(MockBackendOutput)
const link = LiteGraph.createNode(MockBackendLink)
const input = LiteGraph.createNode(MockBackendInput)
const subgraphA = LiteGraph.createNode(Subgraph)
const subgraphB = LiteGraph.createNode(Subgraph)
const graphInputA = subgraphA.addGraphInput("testIn", "number")
const graphOutputA = subgraphA.addGraphOutput("testOut", "number")
const graphInputB = subgraphB.addGraphInput("testIn", "number")
const graphOutputB = subgraphB.addGraphOutput("testOut", "number")
graph.add(subgraphA)
subgraphA.subgraph.add(subgraphB)
graph.add(output)
subgraphB.subgraph.add(link)
graph.add(input)
output.connect(0, subgraphA, 0)
graphInputA.innerNode.connect(0, subgraphB, 0)
graphInputB.innerNode.connect(0, link, 0)
link.connect(0, graphOutputB.innerNode, 0)
subgraphB.connect(0, graphOutputA.innerNode, 0)
subgraphA.connect(0, input, 0)
const result = ser.serialize(graph)
console.warn(graphToGraphVis(graph))
console.warn(result.output)
expect(Object.keys(result.output)).toHaveLength(3);
expect(result.output[input.id].inputs["in"]).toBeInstanceOf(Array)
expect(result.output[input.id].inputs["in"][0]).toEqual(link.id)
expect(result.output[link.id].inputs["in"]).toBeInstanceOf(Array)
expect(result.output[link.id].inputs["in"][0]).toEqual(output.id)
expect(result.output[output.id].inputs).toEqual({})
}
}

4
src/tests/UnitTest.ts Normal file
View File

@@ -0,0 +1,4 @@
export default abstract class UnitTest {
setUp() { }
tearDown() { }
}

81
src/tests/main.ts Normal file
View File

@@ -0,0 +1,81 @@
console.debug = (...msg) => {
}
import { vi, describe, it } from "vitest"
import UnitTest from "./UnitTest"
import * as testSuite from "./testSuite"
import { LiteGraph } from "@litegraph-ts/core"
import "@litegraph-ts/core"
import "@litegraph-ts/nodes-basic"
LiteGraph.use_uuids = true;
// I don't like BDD syntax...
// Emulate minitest instead...
function runTests<T extends UnitTest>(ctor: new () => T) {
const instance = new ctor()
const ctorName = instance.constructor.name
const idx = ctorName.indexOf("Tests")
if (idx === -1) {
throw `Invalid test name ${ctorName}, must end with "Tests"`
}
const classCategory = ctorName.substring(0, idx)
describe(classCategory, () => {
const allTopLevelTests: [string, Function][] = []
const allTests: Record<string, [string, Function][]> = {}
for (const key of Object.getOwnPropertyNames(Object.getPrototypeOf(instance))) {
if (key.startsWith("test")) {
const keys = key.split("__")
let _ = null;
let category = null;
let testName = null;
if (keys.length == 2) {
[_, testName] = keys
}
else {
[_, category, testName] = keys
}
const value = instance[key]
if (typeof value === "function") {
const testFn = () => {
instance.setUp()
value.apply(instance)
instance.tearDown()
}
if (category != null) {
allTests[category] ||= []
allTests[category].push([testName, testFn])
}
else {
allTopLevelTests.push([testName, testFn])
}
}
}
}
for (const [name, testFn] of allTopLevelTests) {
const should = name.split(/\.?(?=[A-Z])/).join(' ').toLowerCase();
it(should, testFn.bind(instance))
}
for (const [category, tests] of Object.entries(allTests)) {
describe(category, () => {
for (const [name, testFn] of tests) {
const should = name.split(/\.?(?=[A-Z])/).join(' ').toLowerCase();
it(should, testFn.bind(instance))
}
})
}
})
}
function runTestSuite() {
for (const ctor of Object.values(testSuite)) {
runTests(ctor as any)
}
}
runTestSuite();

2
src/tests/testSuite.ts Normal file
View File

@@ -0,0 +1,2 @@
export { default as ComfyPromptSerializerTests } from "./ComfyPromptSerializerTests"
export { default as ComfyGraphTests } from "./ComfyGraphTests"

View File

@@ -72,6 +72,13 @@ export default defineConfig({
// }
},
test: {
include: ['litegraph/packages/tests/src/main.ts']
environment: 'jsdom',
deps: {
inline: [/^svelte/, /^@floating-ui/, /dist/, "skeleton-elements", "mdn-polyfills", "loupe"]
},
include: [
'litegraph/packages/tests/src/main.ts',
'src/tests/main.ts'
]
}
});