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> </script>
<body> <body>
<div id="app"/> <div id="app-root"/>
<script type="module" src='/src/main-desktop.ts'></script> <script type="module" src='/src/main-desktop.ts'></script>
</body> </body>
</html> </html>

2
klecks

Submodule klecks updated: a36de3203f...f08ba31888

View File

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

View File

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

331
pnpm-lock.yaml generated
View File

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

View File

@@ -53,7 +53,6 @@
onMount(async () => { onMount(async () => {
await app.setup(); await app.setup();
(window as any).app = app;
window.addEventListener("backbutton", onBackKeyDown, false); window.addEventListener("backbutton", onBackKeyDown, false);
window.addEventListener("popstate", 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 GraphSync from "./GraphSync";
import EventEmitter from "events"; import EventEmitter from "events";
import type TypedEmitter from "typed-emitter"; import type TypedEmitter from "typed-emitter";
@@ -8,7 +8,8 @@ import { get } from "svelte/store";
import type ComfyGraphNode from "./nodes/ComfyGraphNode"; import type ComfyGraphNode from "./nodes/ComfyGraphNode";
import type IComfyInputSlot from "./IComfyInputSlot"; import type IComfyInputSlot from "./IComfyInputSlot";
import type { ComfyBackendNode } from "./nodes/ComfyBackendNode"; 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 = { type ComfyGraphEvents = {
configured: (graph: LGraph) => void configured: (graph: LGraph) => void
@@ -26,29 +27,48 @@ export default class ComfyGraph extends LGraph {
override onConfigure() { override onConfigure() {
console.debug("Configured"); console.debug("Configured");
this.eventBus.emit("configured", this);
} }
override onBeforeChange(graph: LGraph, info: any) { override onBeforeChange(graph: LGraph, info: any) {
console.debug("BeforeChange", info); console.debug("BeforeChange", info);
this.eventBus.emit("beforeChange", graph, info);
} }
override onAfterChange(graph: LGraph, info: any) { override onAfterChange(graph: LGraph, info: any) {
console.debug("AfterChange", info); console.debug("AfterChange", info);
this.eventBus.emit("afterChange", graph, info);
} }
override onAfterExecute() { override onAfterExecute() {
this.eventBus.emit("afterExecute"); 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) { 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) layoutState.nodeAdded(node, options)
// All nodes whether they come from base litegraph or ComfyBox should // All nodes whether they come from base litegraph or ComfyBox should
// have tags added to them. Can't override serialization for existing // 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) if (node.properties.tags == null)
node.properties.tags = [] node.properties.tags = []
@@ -88,7 +108,7 @@ export default class ComfyGraph extends LGraph {
if (!("svelteComponentType" in node) && options.addedBy == null) { if (!("svelteComponentType" in node) && options.addedBy == null) {
console.debug("[ComfyGraph] AutoAdd UI") console.debug("[ComfyGraph] AutoAdd UI")
const comfyNode = node as ComfyGraphNode; const comfyNode = node as ComfyGraphNode;
const widgetNodesAdded = [] const widgetNodesAdded: ComfyWidgetNode[] = []
for (let index = 0; index < comfyNode.inputs.length; index++) { for (let index = 0; index < comfyNode.inputs.length; index++) {
const input = comfyNode.inputs[index]; const input = comfyNode.inputs[index];
if ("config" in input) { if ("config" in input) {
@@ -96,7 +116,7 @@ export default class ComfyGraph extends LGraph {
if (comfyInput.defaultWidgetNode) { if (comfyInput.defaultWidgetNode) {
const widgetNode = LiteGraph.createNode(comfyInput.defaultWidgetNode) const widgetNode = LiteGraph.createNode(comfyInput.defaultWidgetNode)
const inputPos = comfyNode.getConnectionPos(true, index); const inputPos = comfyNode.getConnectionPos(true, index);
this.add(widgetNode) node.graph.add(widgetNode)
widgetNode.connect(0, comfyNode, index); widgetNode.connect(0, comfyNode, index);
widgetNode.collapse(); widgetNode.collapse();
widgetNode.pos = [inputPos[0] - 140, inputPos[1] + LiteGraph.NODE_SLOT_HEIGHT / 2]; 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) const dragItemIDs = widgetNodesAdded.map(wn => get(layoutState).allItemsByNode[wn.id]?.dragItem?.id).filter(Boolean)
console.debug("[ComfyGraph] Group new widgets", dragItems) 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); // Handle nodes in subgraphs being attached to this graph indirectly
this.eventBus.emit("nodeAdded", node); // ************** 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) { override onNodeRemoved(node: LGraphNode, options: LGraphRemoveNodeOptions) {
selectionState.clear(); // safest option
layoutState.nodeRemoved(node, options); 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); this.eventBus.emit("nodeRemoved", node);
} }
override onNodeConnectionChange(kind: LConnectionKind, node: LGraphNode, slot: SlotIndex, targetNode: LGraphNode, targetSlot: SlotIndex) { 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); 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 type ComfyApp from "./components/ComfyApp";
import queueState from "./stores/queueState"; import queueState from "./stores/queueState";
import { get } from "svelte/store"; import { get, type Unsubscriber } from "svelte/store";
import uiState from "./stores/uiState"; import uiState from "./stores/uiState";
import layoutState from "./stores/layoutState"; import layoutState from "./stores/layoutState";
import { Watch } from "@litegraph-ts/nodes-basic"; import { Watch } from "@litegraph-ts/nodes-basic";
import { ComfyReroute } from "./nodes"; import { ComfyReroute } from "./nodes";
import type { Progress } from "./components/ComfyApp";
import selectionState from "./stores/selectionState";
export type SerializedGraphCanvasState = { export type SerializedGraphCanvasState = {
offset: Vector2, offset: Vector2,
@@ -14,6 +16,7 @@ export type SerializedGraphCanvasState = {
export default class ComfyGraphCanvas extends LGraphCanvas { export default class ComfyGraphCanvas extends LGraphCanvas {
app: ComfyApp | null; app: ComfyApp | null;
private _unsubscribe: Unsubscriber;
constructor( constructor(
app: ComfyApp, app: ComfyApp,
@@ -27,13 +30,33 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
) { ) {
super(canvas, app.lGraph, options); super(canvas, app.lGraph, options);
this.app = app; 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 { serialize(): SerializedGraphCanvasState {
return { let offset = this.ds.offset;
offset: this.ds.offset, let scale = this.ds.scale;
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) { deserialize(data: SerializedGraphCanvasState) {
@@ -58,18 +81,36 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
super.drawNodeShape(node, ctx, size, fgColor, bgColor, selected, mouseOver); super.drawNodeShape(node, ctx, size, fgColor, bgColor, selected, mouseOver);
let state = get(queueState); let state = get(queueState);
let ss = get(selectionState);
const isRunningNode = node.id === state.runningNodeID
let color = null; 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"; 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) { if (color) {
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; const shape = node.shape || BuiltInSlotShape.ROUND_SHAPE;
ctx.lineWidth = 1; ctx.lineWidth = outlineThickness;
ctx.globalAlpha = 0.8; ctx.globalAlpha = 0.8;
ctx.beginPath(); ctx.beginPath();
if (shape == BuiltInSlotShape.BOX_SHAPE) if (shape == BuiltInSlotShape.BOX_SHAPE)
@@ -93,17 +134,10 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
); );
else if (shape == BuiltInSlotShape.CIRCLE_SHAPE) 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.arc(size[0] * 0.5, size[1] * 0.5, size[0] * 0.5 + 6, 0, Math.PI * 2);
ctx.strokeStyle = color; ctx.strokeStyle = outlineColor;
ctx.stroke(); ctx.stroke();
ctx.strokeStyle = fgColor; ctx.strokeStyle = fgColor;
ctx.globalAlpha = 1; 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;
}
}
} }
private alignToGrid(node: LGraphNode, ctx: CanvasRenderingContext2D) { private alignToGrid(node: LGraphNode, ctx: CanvasRenderingContext2D) {
@@ -235,10 +269,43 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
} }
override onSelectionChange(nodes: Record<number, LGraphNode>) { override onSelectionChange(nodes: Record<number, LGraphNode>) {
selectionState.update(ss => {
ss.currentSelectionNodes = Object.values(nodes)
ss.currentSelection = []
const ls = get(layoutState) const ls = get(layoutState)
ls.currentSelectionNodes = Object.values(nodes) for (const node of ss.currentSelectionNodes) {
ls.currentSelection = [] const widget = ls.allItemsByNode[node.id]
layoutState.set(ls) 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) { 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,29 +137,9 @@ export class ImageViewer {
} }
} }
setupGalleryImageForLightbox(e: HTMLImageElement) { showLightbox(source: 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) => {
// if (!opts.js_modal_lightbox || evt.button != 0) return;
const initiallyZoomed = true const initiallyZoomed = true
this.modalZoomSet(this.modalImage, initiallyZoomed) this.modalZoomSet(this.modalImage, initiallyZoomed)
evt.preventDefault()
const source = evt.target as HTMLImageElement;
const galleryElem = source.closest<HTMLDivElement>("div.block") const galleryElem = source.closest<HTMLDivElement>("div.block")
console.debug("[ImageViewer] showModal", event, source, galleryElem); console.debug("[ImageViewer] showModal", event, source, galleryElem);
@@ -173,9 +153,6 @@ export class ImageViewer {
console.warn("Gallery!", index, urls, galleryElem) console.warn("Gallery!", index, urls, galleryElem)
this.showModal(urls, index, galleryElem) this.showModal(urls, index, galleryElem)
evt.stopPropagation();
}, true);
} }
modalZoomSet(modalImage: HTMLImageElement, enable: boolean) { 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 type TypedEmitter from "typed-emitter";
import EventEmitter from "events"; 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 { SerializedLGraph, UUID } from "@litegraph-ts/core";
import type { SerializedLayoutState } from "./stores/layoutState"; import type { SerializedLayoutState } from "./stores/layoutState";
import type { ComfyNodeDef } from "./ComfyNodeDef";
export type ComfyPromptRequest = { export type ComfyPromptRequest = {
client_id?: string, client_id?: string,
@@ -30,7 +31,7 @@ export type ComfyAPIQueueResponse = {
error?: string error?: string
} }
export type NodeID = UUID; export type ComfyNodeID = UUID; // To distinguish from Litegraph NodeID
export type PromptID = UUID; // UUID export type PromptID = UUID; // UUID
export type ComfyAPIHistoryItem = [ export type ComfyAPIHistoryItem = [
@@ -38,7 +39,7 @@ export type ComfyAPIHistoryItem = [
PromptID, PromptID,
SerializedPromptInputsAll, SerializedPromptInputsAll,
ComfyBoxPromptExtraData, ComfyBoxPromptExtraData,
NodeID[] // good outputs ComfyNodeID[] // good outputs
] ]
export type ComfyAPIPromptResponse = { export type ComfyAPIPromptResponse = {
@@ -76,9 +77,10 @@ type ComfyAPIEvents = {
progress: (progress: Progress) => void, progress: (progress: Progress) => void,
reconnecting: () => void, reconnecting: () => void,
reconnected: () => void, reconnected: () => void,
executing: (promptID: PromptID | null, runningNodeID: NodeID | null) => void, executing: (promptID: PromptID | null, runningNodeID: ComfyNodeID | null) => void,
executed: (promptID: PromptID, nodeID: NodeID, output: SerializedPromptOutput) => void, executed: (promptID: PromptID, nodeID: ComfyNodeID, output: SerializedPromptOutputs) => void,
execution_cached: (promptID: PromptID, nodes: NodeID[]) => void, execution_start: (promptID: PromptID) => void,
execution_cached: (promptID: PromptID, nodes: ComfyNodeID[]) => void,
execution_error: (promptID: PromptID, message: string) => void, execution_error: (promptID: PromptID, message: string) => void,
} }
@@ -182,6 +184,9 @@ export default class ComfyAPI {
case "executed": case "executed":
this.eventBus.emit("executed", msg.data.prompt_id, msg.data.node, msg.data.output); this.eventBus.emit("executed", msg.data.prompt_id, msg.data.node, msg.data.output);
break; break;
case "execution_start":
this.eventBus.emit("execution_start", msg.data.prompt_id);
break;
case "execution_cached": case "execution_cached":
this.eventBus.emit("execution_cached", msg.data.prompt_id, msg.data.nodes); this.eventBus.emit("execution_cached", msg.data.prompt_id, msg.data.nodes);
break; break;
@@ -226,7 +231,7 @@ export default class ComfyAPI {
* Loads node object definitions for the graph * Loads node object definitions for the graph
* @returns The node definitions * @returns The node definitions
*/ */
async getNodeDefs(): Promise<any> { async getNodeDefs(): Promise<Record<ComfyNodeID, ComfyNodeDef>> {
return fetch(this.getBackendUrl() + "/object_info", { cache: "no-store" }) return fetch(this.getBackendUrl() + "/object_info", { cache: "no-store" })
.then(resp => resp.json()) .then(resp => resp.json())
} }
@@ -248,7 +253,7 @@ export default class ComfyAPI {
postBody = JSON.stringify(body) postBody = JSON.stringify(body)
} }
catch (error) { catch (error) {
return Promise.reject({ error }) return Promise.reject({ error: error.toString() })
} }
return fetch(this.getBackendUrl() + "/prompt", { return fetch(this.getBackendUrl() + "/prompt", {
@@ -260,12 +265,12 @@ export default class ComfyAPI {
}) })
.then(async (res) => { .then(async (res) => {
if (res.status != 200) { if (res.status != 200) {
throw await res.text() throw await res.json()
} }
return res.json() return res.json()
}) })
.then(raw => { return { promptID: raw.prompt_id } }) .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 { Block, BlockTitle } from "@gradio/atoms";
import Accordion from "$lib/components/gradio/app/Accordion.svelte"; import Accordion from "$lib/components/gradio/app/Accordion.svelte";
import uiState from "$lib/stores/uiState"; import uiState from "$lib/stores/uiState";
import selectionState from "$lib/stores/selectionState";
import WidgetContainer from "./WidgetContainer.svelte" import WidgetContainer from "./WidgetContainer.svelte"
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action'; import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
@@ -60,10 +61,11 @@
} }
</script> </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}" <div class="container {container.attrs.direction} {container.attrs.classes} {classes.join(' ')} z-index{zIndex}"
class:hide-block={container.attrs.containerVariant === "hidden"} class:hide-block={container.attrs.containerVariant === "hidden"}
class:selected={$uiState.uiUnlocked && $layoutState.currentSelection.includes(container.id)} class:selected
class:root-container={zIndex === 0} class:root-container={zIndex === 0}
class:is-executing={container.isNodeExecuting} class:is-executing={container.isNodeExecuting}
class:edit={edit}> class:edit={edit}>
@@ -120,6 +122,10 @@
<style lang="scss"> <style lang="scss">
.container { .container {
&.selected {
background: var(--comfy-container-selected-background-fill) !important;
}
> :global(*) { > :global(*) {
border-radius: 0; border-radius: 0;
} }

View File

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

View File

@@ -9,6 +9,7 @@
import { Checkbox, TextBox } from "@gradio/form" import { Checkbox, TextBox } from "@gradio/form"
import uiState from "$lib/stores/uiState"; import uiState from "$lib/stores/uiState";
import layoutState from "$lib/stores/layoutState"; import layoutState from "$lib/stores/layoutState";
import selectionState from "$lib/stores/selectionState";
import { ImageViewer } from "$lib/ImageViewer"; import { ImageViewer } from "$lib/ImageViewer";
import type { ComfyAPIStatus } from "$lib/api"; import type { ComfyAPIStatus } from "$lib/api";
import { SvelteToast, toast } from '@zerodevx/svelte-toast' import { SvelteToast, toast } from '@zerodevx/svelte-toast'
@@ -54,12 +55,12 @@
if (!$uiState.uiUnlocked) { if (!$uiState.uiUnlocked) {
app.lCanvas.deselectAllNodes(); app.lCanvas.deselectAllNodes();
$layoutState.currentSelectionNodes = [] $selectionState.currentSelectionNodes = []
} }
} }
$: if ($uiState.uiEditMode) $: if ($uiState.uiEditMode)
$layoutState.currentSelection = [] $selectionState.currentSelection = []
let graphSize = 0; let graphSize = 0;
let graphTransitioning = false; let graphTransitioning = false;
@@ -171,8 +172,6 @@
onMount(async () => { onMount(async () => {
await app.setup(); await app.setup();
(window as any).app = app;
(window as any).appPane = uiPane;
// await import('../../scss/ux.scss'); // await import('../../scss/ux.scss');
@@ -184,10 +183,10 @@
} }
$: if (uiTheme === "gradio-dark") { $: if (uiTheme === "gradio-dark") {
document.getElementById("app").classList.add("dark") document.getElementById("app-root").classList.add("dark")
} }
else { else {
document.getElementById("app").classList.remove("dark") document.getElementById("app-root").classList.remove("dark")
} }
</script> </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 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 { getPngMetadata, importA1111 } from "$lib/pnginfo";
import EventEmitter from "events"; import EventEmitter from "events";
import type TypedEmitter from "typed-emitter"; import type TypedEmitter from "typed-emitter";
@@ -12,11 +12,12 @@ import "@litegraph-ts/nodes-logic"
import "@litegraph-ts/nodes-math" import "@litegraph-ts/nodes-math"
import "@litegraph-ts/nodes-strings" import "@litegraph-ts/nodes-strings"
import "$lib/nodes/index" import "$lib/nodes/index"
import "$lib/nodes/widgets/index"
import * as nodes from "$lib/nodes/index" import * as nodes from "$lib/nodes/index"
import * as widgets from "$lib/nodes/widgets/index"
import ComfyGraphCanvas, { type SerializedGraphCanvasState } from "$lib/ComfyGraphCanvas"; import ComfyGraphCanvas, { type SerializedGraphCanvasState } from "$lib/ComfyGraphCanvas";
import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode"; import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode";
import * as widgets from "$lib/widgets/index"
import queueState from "$lib/stores/queueState"; import queueState from "$lib/stores/queueState";
import { type SvelteComponentDev } from "svelte/internal"; import { type SvelteComponentDev } from "svelte/internal";
import type IComfyInputSlot from "$lib/IComfyInputSlot"; import type IComfyInputSlot from "$lib/IComfyInputSlot";
@@ -28,18 +29,17 @@ import { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { tick } from "svelte"; import { tick } from "svelte";
import uiState from "$lib/stores/uiState"; 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 notify from "$lib/notify";
import configState from "$lib/stores/configState"; import configState from "$lib/stores/configState";
import { blankGraph } from "$lib/defaultGraph"; 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; 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") { if (typeof window !== "undefined") {
// Load default visibility // Load default visibility
nodes.ComfyReroute.setDefaultTextVisibility(!!localStorage["Comfy.ComfyReroute.DefaultVisibility"]); nodes.ComfyReroute.setDefaultTextVisibility(!!localStorage["Comfy.ComfyReroute.DefaultVisibility"]);
@@ -56,7 +56,7 @@ export type SerializedAppState = {
} }
/** [link origin, link index] | value */ /** [link origin, link index] | value */
export type SerializedPromptInput = [NodeID, number] | any export type SerializedPromptInput = [ComfyNodeID, number] | any
export type SerializedPromptInputs = { export type SerializedPromptInputs = {
/* property name -> value or link */ /* property name -> value or link */
@@ -64,14 +64,14 @@ export type SerializedPromptInputs = {
class_type: string class_type: string
} }
export type SerializedPromptInputsAll = Record<NodeID, SerializedPromptInputs> export type SerializedPromptInputsAll = Record<ComfyNodeID, SerializedPromptInputs>
export type SerializedPrompt = { export type SerializedPrompt = {
workflow: SerializedLGraph, workflow: SerializedLGraph,
output: SerializedPromptInputsAll output: SerializedPromptInputsAll
} }
export type SerializedPromptOutputs = Record<NodeID, ComfyExecutionResult> export type SerializedPromptOutputs = Record<ComfyNodeID, ComfyExecutionResult>
export type Progress = { export type Progress = {
value: number, value: number,
@@ -79,32 +79,11 @@ export type Progress = {
} }
type BackendComboNode = { type BackendComboNode = {
comboNode: nodes.ComfyComboNode comboNode: ComfyComboNode,
inputSlot: IComfyInputSlot, comfyInput: IComfyInputSlot,
backendNode: ComfyBackendNode 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 { export default class ComfyApp {
api: ComfyAPI; api: ComfyAPI;
rootEl: HTMLDivElement | null = null; rootEl: HTMLDivElement | null = null;
@@ -115,16 +94,17 @@ export default class ComfyApp {
dropZone: HTMLElement | null = null; dropZone: HTMLElement | null = null;
nodeOutputs: Record<string, any> = {}; nodeOutputs: Record<string, any> = {};
dragOverNode: LGraphNode | null = null;
shiftDown: boolean = false; shiftDown: boolean = false;
selectedGroupMoving: boolean = false; selectedGroupMoving: boolean = false;
private queueItems: QueueItem[] = []; private queueItems: QueueItem[] = [];
private processingQueue: boolean = false; private processingQueue: boolean = false;
private alreadySetup = false; private alreadySetup = false;
private promptSerializer: ComfyPromptSerializer;
constructor() { constructor() {
this.api = new ComfyAPI(); this.api = new ComfyAPI();
this.promptSerializer = new ComfyPromptSerializer();
} }
async setup(): Promise<void> { async setup(): Promise<void> {
@@ -135,21 +115,16 @@ export default class ComfyApp {
this.setupColorScheme() 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.canvasEl = document.getElementById("graph-canvas") as HTMLCanvasElement;
this.lGraph = new ComfyGraph(); this.lGraph = new ComfyGraph();
this.lCanvas = new ComfyGraphCanvas(this, this.canvasEl); this.lCanvas = new ComfyGraphCanvas(this, this.canvasEl);
this.canvasCtx = this.canvasEl.getContext("2d"); 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; const uiUnlocked = get(uiState).uiUnlocked;
this.lCanvas.allow_dragnodes = uiUnlocked; this.lCanvas.allow_dragnodes = uiUnlocked;
this.lCanvas.allow_interaction = uiUnlocked; this.lCanvas.allow_interaction = uiUnlocked;
(window as any).LiteGraph = LiteGraph;
// await this.#invokeExtensionsAsync("init"); // await this.#invokeExtensionsAsync("init");
await this.registerNodes(); await this.registerNodes();
@@ -223,15 +198,11 @@ export default class ComfyApp {
static widget_type_overrides: Record<string, typeof SvelteComponentDev> = {} static widget_type_overrides: Record<string, typeof SvelteComponentDev> = {}
private async registerNodes() { private async registerNodes() {
const app = this;
// Load node definitions from the backend // Load node definitions from the backend
const defs = await this.api.getNodeDefs(); const defs = await this.api.getNodeDefs();
// Register a node for each definition // Register a node for each definition
for (const nodeId in defs) { for (const [nodeId, nodeDef] of Object.entries(defs)) {
const nodeData = defs[nodeId];
const typeOverride = ComfyApp.node_type_overrides[nodeId] const typeOverride = ComfyApp.node_type_overrides[nodeId]
if (typeOverride) if (typeOverride)
console.debug("Attaching custom type to received node:", nodeId, typeOverride) console.debug("Attaching custom type to received node:", nodeId, typeOverride)
@@ -239,19 +210,79 @@ export default class ComfyApp {
const ctor = class extends baseClass { const ctor = class extends baseClass {
constructor(title?: string) { constructor(title?: string) {
super(title, nodeId, nodeData); super(title, nodeId, nodeDef);
} }
} }
const node: LGraphNodeConstructor = { const node: LGraphNodeConstructor = {
class: ctor, class: ctor,
title: nodeData.display_name || nodeData.name, title: nodeDef.display_name || nodeDef.name,
type: nodeId, type: nodeId,
desc: `ComfyNode: ${nodeId}` desc: `ComfyNode: ${nodeId}`
} }
LiteGraph.registerNodeType(node); 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.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); queueState.executingUpdated(promptID, nodeID);
this.lGraph.setDirtyCanvas(true, false); 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; this.nodeOutputs[nodeID] = output;
const node = this.lGraph.getNodeById(nodeID) as ComfyGraphNode; const node = this.lGraph.getNodeByIdRecursive(nodeID) as ComfyGraphNode;
if (node?.onExecuted) { if (node?.onExecuted) {
node.onExecuted(output); node.onExecuted(output);
} }
queueState.onExecuted(promptID, nodeID, 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) queueState.executionCached(promptID, nodes)
}); });
@@ -403,7 +438,7 @@ export default class ComfyApp {
} }
// Distinguish frontend/backend connections // 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) { for (const type of BACKEND_TYPES) {
setColor(type, "orange") setColor(type, "orange")
} }
@@ -508,6 +543,7 @@ export default class ComfyApp {
} }
layoutState.onStartConfigure(); layoutState.onStartConfigure();
this.lCanvas.closeAllSubgraphs();
this.lGraph.configure(blankGraph) this.lGraph.configure(blankGraph)
layoutState.initDefaultLayout(); layoutState.initDefaultLayout();
uiState.update(s => { uiState.update(s => {
@@ -518,7 +554,7 @@ export default class ComfyApp {
} }
runDefaultQueueAction() { runDefaultQueueAction() {
for (const node of this.lGraph.iterateNodesInOrder()) { for (const node of this.lGraph.iterateNodesInOrderRecursive()) {
if ("onDefaultQueueAction" in node) { if ("onDefaultQueueAction" in node) {
(node as ComfyGraphNode).onDefaultQueueAction() (node as ComfyGraphNode).onDefaultQueueAction()
} }
@@ -559,157 +595,7 @@ export default class ComfyApp {
* @returns The workflow and node links * @returns The workflow and node links
*/ */
graphToPrompt(tag: string | null = null): SerializedPrompt { graphToPrompt(tag: string | null = null): SerializedPrompt {
// Run frontend-only logic return this.promptSerializer.serialize(this.lGraph, tag)
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 };
} }
async queuePrompt(num: number, batchCount: number = 1, tag: string | null = null) { 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 p = this.graphToPrompt(tag);
const l = layoutState.serialize(); const l = layoutState.serialize();
console.debug(graphToGraphVis(this.lGraph))
console.debug(promptToGraphVis(p)) console.debug(promptToGraphVis(p))
const extraData: ComfyBoxPromptExtraData = { const extraData: ComfyBoxPromptExtraData = {
@@ -767,8 +654,8 @@ export default class ComfyApp {
thumbnails thumbnails
} }
let error = null; let error: string | null = null;
let promptID = null; let promptID: PromptID | null = null;
const request: ComfyPromptRequest = { const request: ComfyPromptRequest = {
number: num, number: num,
@@ -778,28 +665,26 @@ export default class ComfyApp {
try { try {
const response = await this.api.queuePrompt(request); const response = await this.api.queuePrompt(request);
if (response.error != null) {
// 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; error = response.error;
}
else {
queueState.afterQueued(response.promptID, num, p.output, extraData)
}
} catch (err) { } catch (err) {
error = err error = err?.toString();
} }
if (error != null) { if (error != null) {
const mes = error.response || error.toString() const mes: string = error;
notify(`Error queuing prompt:\n${mes}`, { type: "error" }) notify(`Error queuing prompt:\n${mes}`, { type: "error" })
console.error(graphToGraphVis(this.lGraph))
console.error(promptToGraphVis(p)) console.error(promptToGraphVis(p))
console.error("Error queuing prompt", error, num, p) console.error("Error queuing prompt", error, num, p)
break; break;
} }
for (const n of p.workflow.nodes) { for (const node of this.lGraph.iterateNodesInOrderRecursive()) {
const node = this.lGraph.getNodeById(n.id);
if ("afterQueued" in node) { if ("afterQueued" in node) {
(node as ComfyGraphNode).afterQueued(p, tag); (node as ComfyGraphNode).afterQueued(p, tag);
} }
@@ -857,67 +742,77 @@ export default class ComfyApp {
async refreshComboInNodes(flashUI: boolean = false) { async refreshComboInNodes(flashUI: boolean = false) {
const defs = await this.api.getNodeDefs(); const defs = await this.api.getNodeDefs();
const toUpdate: BackendComboNode[] = [] const isComfyComboNode = (node: LGraphNode): node is ComfyComboNode => {
const isComfyComboNode = (node: LGraphNode): boolean => {
return node return node
&& node.type === "ui/combo" && node.type === "ui/combo"
&& "doAutoConfig" in node; && "doAutoConfig" in node;
} }
const isComfyComboInput = (input: INodeInputSlot) => { const isComfyComboInput = (input: INodeInputSlot): input is IComfyInputSlot => {
return "config" in input return "config" in input
&& "widgetNodeType" in input && "widgetNodeType" in input
&& input.widgetNodeType === "ui/combo"; && input.widgetNodeType === "ui/combo";
} }
// Node IDs of combo widgets attached to a backend node // 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") console.debug("[refreshComboInNodes] start")
// Figure out which combo nodes to update. They need to be connected to // 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 // an input slot on a backend node with a backend config in the input
// slot connected to. // slot connected to.
for (const node of this.lGraph.iterateNodesInOrder()) { const nodeLocator = new UpstreamNodeLocator(isComfyComboNode)
if (!(node as any).isBackendNode)
continue;
const backendNode = (node as ComfyBackendNode) const findComfyInputAndAttachedCombo = (node: LGraphNode, i: SlotIndex): [IComfyInputSlot, ComfyComboNode] | null => {
const found = range(backendNode.inputs.length) const input = node.inputs[i]
.filter(i => {
const input = backendNode.inputs[i]
const inputNode = backendNode.getInputNode(i)
// Does this input autocreate a combo box on creation? // Does this input autocreate a combo box on creation?
const isComfyInput = isComfyComboInput(input) const isComfyInput = isComfyComboInput(input)
const isComfyCombo = isComfyComboNode(inputNode) if (!isComfyInput)
return null;
// console.debug("[refreshComboInNodes] CHECK", backendNode.type, input.name, "isComfyCombo", isComfyCombo, "isComfyInput", isComfyInput) // 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);
return isComfyCombo && isComfyInput if (comboNode == null)
}); return null;
for (const inputIndex of found) { const result: [IComfyInputSlot, ComfyComboNode] = [input, comboNode as ComfyComboNode]
const comboNode = backendNode.getInputNode(inputIndex) as nodes.ComfyComboNode return result
const inputSlot = backendNode.inputs[inputIndex] as IComfyInputSlot; }
const def = defs[backendNode.type];
const hasBackendConfig = def["input"]["required"][inputSlot.name] !== undefined for (const node of this.lGraph.iterateNodesInOrderRecursive()) {
if (!isActiveBackendNode(node))
continue;
const found = range(node.inputs.length)
.map((i) => findComfyInputAndAttachedCombo(node, i))
.filter(Boolean);
for (const [comfyInput, comboNode] of found) {
const def = defs[node.type];
const hasBackendConfig = def["input"]["required"][comfyInput.name] !== undefined
if (hasBackendConfig) { if (hasBackendConfig) {
backendCombos.add(comboNode.id) backendUpdatedCombos[comboNode.id] = { comboNode, comfyInput, backendNode: node }
toUpdate.push({ comboNode, inputSlot, backendNode })
} }
} }
} }
console.debug("[refreshComboInNodes] found:", toUpdate.length, toUpdate) console.debug("[refreshComboInNodes] found:", backendUpdatedCombos.length, backendUpdatedCombos)
// Mark combo nodes without backend configs as being loaded already. // Mark combo nodes without backend configs as being loaded already.
for (const node of this.lGraph.iterateNodesInOrder()) { for (const node of this.lGraph.iterateNodesOfClassRecursive(ComfyComboNode)) {
if (isComfyComboNode(node) && !backendCombos.has(node.id)) { if (backendUpdatedCombos[node.id] != null) {
const comboNode = node as nodes.ComfyComboNode; 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; let values = comboNode.properties.values;
// Frontend nodes can declare defaultWidgets which creates a // Frontend nodes can declare defaultWidgets which creates a
@@ -926,29 +821,30 @@ export default class ComfyApp {
.flatMap(i => node.getInputSlotsConnectedTo(i)) .flatMap(i => node.getInputSlotsConnectedTo(i))
.find(inp => "config" in inp && Array.isArray((inp.config as any).values)) .find(inp => "config" in inp && Array.isArray((inp.config as any).values))
let defaultValue = null;
if (foundInput != null) { if (foundInput != null) {
const comfyInput = foundInput as IComfyInputSlot; const comfyInput = foundInput as IComfyInputSlot;
console.warn("[refreshComboInNodes] found frontend config:", node.title, node.type, comfyInput.config.values) console.warn("[refreshComboInNodes] found frontend config:", node.title, node.type, comfyInput.config.values)
values = comfyInput.config.values; values = comfyInput.config.values;
defaultValue = comfyInput.config.defaultValue;
} }
comboNode.formatValues(values); comboNode.formatValues(values, defaultValue);
}
} }
await tick(); await tick();
// Load definitions from the backend. // 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 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) 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))) { 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] { input[disabled] {
cursor: not-allowed; 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 { LGraphNode } from "@litegraph-ts/core"
import layoutState, { type IDragItem, type WidgetLayout, ALL_ATTRIBUTES, type AttributesSpec } from "$lib/stores/layoutState" import layoutState, { type IDragItem, type WidgetLayout, ALL_ATTRIBUTES, type AttributesSpec } from "$lib/stores/layoutState"
import uiState from "$lib/stores/uiState" import uiState from "$lib/stores/uiState"
import selectionState from "$lib/stores/selectionState"
import { get, type Writable, writable } from "svelte/store" import { get, type Writable, writable } from "svelte/store"
import type { ComfyWidgetNode } from "$lib/nodes";
import ComfyNumberProperty from "./ComfyNumberProperty.svelte"; import ComfyNumberProperty from "./ComfyNumberProperty.svelte";
import ComfyComboProperty from "./ComfyComboProperty.svelte"; import ComfyComboProperty from "./ComfyComboProperty.svelte";
import type { ComfyWidgetNode } from "$lib/nodes/widgets";
let target: IDragItem | null = null; let target: IDragItem | null = null;
let node: LGraphNode | null = null; let node: LGraphNode | null = null;
@@ -17,20 +18,21 @@
$: refreshPropsPanel = $layoutState.refreshPropsPanel; $: refreshPropsPanel = $layoutState.refreshPropsPanel;
$: if ($layoutState.currentSelection.length > 0) { $: if ($selectionState.currentSelection.length > 0) {
const targetId = $layoutState.currentSelection.slice(-1)[0] node = null;
target = $layoutState.allItems[targetId].dragItem const targetId = $selectionState.currentSelection.slice(-1)[0]
const entry = $layoutState.allItems[targetId]
if (entry != null) {
target = entry.dragItem
attrsChanged = target.attrsChanged; attrsChanged = target.attrsChanged;
if (target.type === "widget") { if (target.type === "widget") {
node = (target as WidgetLayout).node node = (target as WidgetLayout).node
} }
else {
node = null;
} }
} }
else if ($layoutState.currentSelectionNodes.length > 0) { else if ($selectionState.currentSelectionNodes.length > 0) {
target = null; target = null;
node = $layoutState.currentSelectionNodes[0] node = $selectionState.currentSelectionNodes[0]
attrsChanged = null; attrsChanged = null;
} }
else { else {
@@ -130,7 +132,7 @@
value = spec.defaultValue value = spec.defaultValue
else if (spec.serialize) else if (spec.serialize)
value = spec.serialize(value) value = spec.serialize(value)
console.debug("[ComfyProperties] getAttribute", spec.name, value, target, spec) // console.debug("[ComfyProperties] getAttribute", spec.name, value, target, spec)
return value return value
} }
@@ -140,13 +142,17 @@
const name = spec.name const name = spec.name
console.debug("[ComfyProperties] updateAttribute", spec, value, name, node) // console.debug("[ComfyProperties] updateAttribute", spec, value, name, node)
if (spec.deserialize) if (spec.deserialize)
value = spec.deserialize(value) value = spec.deserialize(value)
const prevValue = target.attrs[name]
target.attrs[name] = value target.attrs[name] = value
target.attrsChanged.set(get(target.attrsChanged) + 1) target.attrsChanged.set(get(target.attrsChanged) + 1)
if (spec.onChanged)
spec.onChanged(target, value, prevValue)
if (node && "propsChanged" in node) { if (node && "propsChanged" in node) {
const comfyNode = node as ComfyWidgetNode const comfyNode = node as ComfyWidgetNode
comfyNode.propsChanged.set(get(comfyNode.propsChanged) + 1) comfyNode.propsChanged.set(get(comfyNode.propsChanged) + 1)
@@ -164,7 +170,7 @@
value = spec.defaultValue value = spec.defaultValue
else if (spec.serialize) else if (spec.serialize)
value = spec.serialize(value) value = spec.serialize(value)
console.debug("[ComfyProperties] getProperty", spec, value, node) // console.debug("[ComfyProperties] getProperty", spec, value, node)
return value return value
} }
@@ -173,13 +179,17 @@
return return
const name = spec.name const name = spec.name
console.warn("[ComfyProperties] updateProperty", name, value) // console.warn("[ComfyProperties] updateProperty", name, value)
if (spec.deserialize) if (spec.deserialize)
value = spec.deserialize(value) value = spec.deserialize(value)
const prevValue = node.properties[name]
node.properties[name] = value; node.properties[name] = value;
if (spec.onChanged)
spec.onChanged(node, value, prevValue)
if ("propsChanged" in node) { if ("propsChanged" in node) {
const comfyNode = node as ComfyWidgetNode const comfyNode = node as ComfyWidgetNode
comfyNode.notifyPropsChanged(); comfyNode.notifyPropsChanged();
@@ -195,7 +205,7 @@
value = spec.defaultValue value = spec.defaultValue
else if (spec.serialize) else if (spec.serialize)
value = spec.serialize(value) value = spec.serialize(value)
console.debug("[ComfyProperties] getVar", spec, value, node) // console.debug("[ComfyProperties] getVar", spec, value, node)
return value return value
} }
@@ -205,12 +215,16 @@
const name = spec.name const name = spec.name
console.debug("[ComfyProperties] updateVar", spec, value, name, node) // console.debug("[ComfyProperties] updateVar", spec, value, name, node)
if (spec.deserialize) if (spec.deserialize)
value = spec.deserialize(value) value = spec.deserialize(value)
const prevValue = node[name]
node[name] = value; node[name] = value;
if (spec.onChanged)
spec.onChanged(node, value, prevValue)
if ("propsChanged" in node) { if ("propsChanged" in node) {
const comfyNode = node as ComfyWidgetNode const comfyNode = node as ComfyWidgetNode
comfyNode.propsChanged.set(get(comfyNode.propsChanged) + 1) comfyNode.propsChanged.set(get(comfyNode.propsChanged) + 1)
@@ -227,7 +241,7 @@
value = spec.defaultValue value = spec.defaultValue
else if (spec.serialize) else if (spec.serialize)
value = spec.serialize(value) 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 return value
} }
@@ -236,11 +250,15 @@
return; return;
const name = spec.name const name = spec.name
console.warn("updateWorkflowAttribute", name, value) // console.warn("[ComfyProperties] updateWorkflowAttribute", name, value)
const prevValue = value
$layoutState.attrs[name] = value $layoutState.attrs[name] = value
$layoutState = $layoutState $layoutState = $layoutState
if (spec.onChanged)
spec.onChanged($layoutState, value, prevValue)
if (spec.refreshPanelOnChange) if (spec.refreshPanelOnChange)
doRefreshPanel() doRefreshPanel()
} }

View File

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

View File

@@ -8,6 +8,7 @@
import WidgetContainer from "./WidgetContainer.svelte"; import WidgetContainer from "./WidgetContainer.svelte";
import layoutState, { type ContainerLayout, type DragItem, type IDragItem } from "$lib/stores/layoutState"; import layoutState, { type ContainerLayout, type DragItem, type IDragItem } from "$lib/stores/layoutState";
import uiState from "$lib/stores/uiState"; import uiState from "$lib/stores/uiState";
import selectionState from "$lib/stores/selectionState";
import Menu from './menu/Menu.svelte'; import Menu from './menu/Menu.svelte';
import MenuOption from './menu/MenuOption.svelte'; import MenuOption from './menu/MenuOption.svelte';
@@ -29,19 +30,72 @@
// TODO // 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) { function groupWidgets(horizontal: boolean) {
const items = layoutState.getCurrentSelection() const items = $selectionState.currentSelection
$layoutState.currentSelection = [] $selectionState.currentSelection = []
layoutState.groupItems(items, { direction: horizontal ? "horizontal" : "vertical" }) layoutState.groupItems(items, { direction: horizontal ? "horizontal" : "vertical" })
} }
let canUngroup = false; let canUngroup = false;
let isDeleteGroup = 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) { $: if (canUngroup) {
const dragItem = layoutState.getCurrentSelection()[0]; const dragItemID = $selectionState.currentSelection[0];
const entry = $layoutState.allItems[dragItem.id]; const entry = $layoutState.allItems[dragItemID];
isDeleteGroup = entry.children.length === 0 isDeleteGroup = entry.children.length === 0
} }
else { else {
@@ -49,11 +103,19 @@
} }
function ungroup() { function ungroup() {
const item = layoutState.getCurrentSelection()[0] const itemID = $selectionState.currentSelection[0]
if (!item || item.type !== "container") if (itemID == null)
return; 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) layoutState.ungroup(item as ContainerLayout)
} }
@@ -94,11 +156,28 @@
{#if showMenu} {#if showMenu}
<Menu {...menuPos} on:click={closeMenu} on:clickoutside={closeMenu}> <Menu {...menuPos} on:click={closeMenu} on:clickoutside={closeMenu}>
<MenuOption <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)} on:click={() => groupWidgets(false)}
text="Group" /> text="Group" />
<MenuOption <MenuOption
isDisabled={$layoutState.currentSelection.length === 0} isDisabled={$selectionState.currentSelection.length === 0}
on:click={() => groupWidgets(true)} on:click={() => groupWidgets(true)}
text="Group Horizontally" /> text="Group Horizontally" />
<MenuOption <MenuOption

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,8 +11,8 @@
export let label: string | undefined = undefined; export let label: string | undefined = undefined;
export let show_label: boolean; export let show_label: boolean;
export let selectable: boolean = false; export let selectable: boolean = false;
export let imageWidth: number = 1; export let imageWidth: number = 0;
export let imageHeight: number = 1; export let imageHeight: number = 0;
let imageElem: HTMLImageElement | null = null; let imageElem: HTMLImageElement | null = null;
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
@@ -23,8 +23,8 @@
$: value && dispatch("change", value); $: value && dispatch("change", value);
$: if (value == null || !imageElem) { $: if (value == null || !imageElem) {
imageWidth = 1; imageWidth = 0;
imageHeight = 1; imageHeight = 0;
} }
const handle_click = (evt: MouseEvent) => { 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 { BuiltInSlotType, LiteGraph, NodeMode, type ITextWidget, type IToggleWidget, type SerializedLGraphNode, type SlotLayout, type PropertyLayout } from "@litegraph-ts/core";
import { get } from "svelte/store"; import { get } from "svelte/store";
import ComfyGraphNode, { type ComfyGraphNodeProperties } from "./ComfyGraphNode"; 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 { NotifyOptions } from "$lib/notify";
import type { FileData as GradioFileData } from "@gradio/upload"; 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 { export class ComfyQueueEvents extends ComfyGraphNode {
static slotLayout: SlotLayout = { static slotLayout: SlotLayout = {
@@ -59,7 +59,7 @@ LiteGraph.registerNodeType({
class: ComfyQueueEvents, class: ComfyQueueEvents,
title: "Comfy.QueueEvents", title: "Comfy.QueueEvents",
desc: "Triggers a 'bang' event when a prompt is queued.", desc: "Triggers a 'bang' event when a prompt is queued.",
type: "actions/queue_events" type: "events/queue_events"
}) })
export interface ComfyStoreImagesActionProperties extends ComfyGraphNodeProperties { export interface ComfyStoreImagesActionProperties extends ComfyGraphNodeProperties {
@@ -177,8 +177,8 @@ export class ComfySwapAction extends ComfyGraphNode {
override onAction(action: any, param: any) { override onAction(action: any, param: any) {
const a = this.getInputData(0) const a = this.getInputData(0)
const b = this.getInputData(1) const b = this.getInputData(1)
this.triggerSlot(0, a) this.triggerSlot(0, b)
this.triggerSlot(1, 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>) { 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) { if ("tags" in node.properties) {
const comfyNode = node as ComfyGraphNode; const comfyNode = node as ComfyGraphNode;
const hasTag = comfyNode.properties.tags.indexOf(action.tag) != -1; const hasTag = comfyNode.properties.tags.indexOf(action.tag) != -1;
@@ -482,9 +482,6 @@ export class ComfySetNodeModeAdvancedAction extends ComfyGraphNode {
newMode = NodeMode.NEVER; newMode = NodeMode.NEVER;
} }
nodeChanges[node.id] = newMode 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)) { for (const [nodeId, newMode] of Object.entries(nodeChanges)) {
this.graph.getNodeById(nodeId).changeMode(newMode); this.graph.getNodeByIdRecursive(nodeId).changeMode(newMode);
} }
const layout = get(layoutState); const layout = get(layoutState);
@@ -601,27 +598,7 @@ export class ComfySetPromptThumbnailsAction extends ComfyGraphNode {
override getPromptThumbnails(): ComfyImageLocation[] | null { override getPromptThumbnails(): ComfyImageLocation[] | null {
const data = this.getInputData(0) const data = this.getInputData(0)
return parseWhateverIntoComfyImageLocations(data);
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;
} }
} }

View File

@@ -1,10 +1,12 @@
import LGraphCanvas from "@litegraph-ts/core/src/LGraphCanvas"; import LGraphCanvas from "@litegraph-ts/core/src/LGraphCanvas";
import ComfyGraphNode from "./ComfyGraphNode"; import ComfyGraphNode from "./ComfyGraphNode";
import ComfyWidgets from "$lib/widgets" 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 { BuiltInSlotShape, BuiltInSlotType, type SerializedLGraphNode } from "@litegraph-ts/core";
import type IComfyInputSlot from "$lib/IComfyInputSlot"; import type IComfyInputSlot from "$lib/IComfyInputSlot";
import type { ComfyInputConfig } 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. * 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. // It just returns a hash like { "ui": { "images": results } } internally.
// So this will need to be hardcoded for now. // So this will need to be hardcoded for now.
if (["PreviewImage", "SaveImage"].indexOf(comfyClass) !== -1) { 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 // comfy class -> input name -> input config
private static defaultInputConfigs: Record<string, Record<string, ComfyInputConfig>> = {} private static defaultInputConfigs: Record<string, Record<string, ComfyInputConfig>> = {}
private setup(nodeData: any) { private setup(nodeDef: ComfyNodeDef) {
var inputs = nodeData["input"]["required"];
if (nodeData["input"]["optional"] != undefined) {
inputs = Object.assign({}, nodeData["input"]["required"], nodeData["input"]["optional"])
}
ComfyBackendNode.defaultInputConfigs[this.type] = {} ComfyBackendNode.defaultInputConfigs[this.type] = {}
for (const inputName in inputs) { for (const [inputName, inputData] of iterateNodeDefInputs(nodeDef)) {
const config: Partial<IComfyInputSlot> = {}; const config: Partial<IComfyInputSlot> = {};
const inputData = inputs[inputName]; const [type, opts] = inputData;
const type = inputData[0];
if (inputData[1]?.forceInput) { if (opts?.forceInput) {
this.addInput(inputName, type); 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 { } else {
if (Array.isArray(type)) { if (Array.isArray(type)) {
// Enums // Enums
@@ -73,11 +72,9 @@ export class ComfyBackendNode extends ComfyGraphNode {
ComfyBackendNode.defaultInputConfigs[this.type][inputName] = (config as IComfyInputSlot).config ComfyBackendNode.defaultInputConfigs[this.type][inputName] = (config as IComfyInputSlot).config
} }
for (const o in nodeData["output"]) { for (const output of iterateNodeDefOutputs(nodeDef)) {
const output = nodeData["output"][o]; const outputShape = output.is_list ? BuiltInSlotShape.GRID_SHAPE : BuiltInSlotShape.CIRCLE_SHAPE;
const outputName = nodeData["output_name"][o] || output; this.addOutput(output.name, output.type, { shape: outputShape });
const outputShape = nodeData["output_is_list"][o] ? BuiltInSlotShape.GRID_SHAPE : BuiltInSlotShape.CIRCLE_SHAPE;
this.addOutput(outputName, output, { shape: outputShape });
} }
this.serialize_widgets = false; this.serialize_widgets = false;

View File

@@ -1,9 +1,9 @@
import type { ComfyInputConfig } from "$lib/IComfyInputSlot"; import type { ComfyInputConfig } from "$lib/IComfyInputSlot";
import type { SerializedPrompt } from "$lib/components/ComfyApp"; 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 { 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 { 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 type IComfyInputSlot from "$lib/IComfyInputSlot";
import uiState from "$lib/stores/uiState"; import uiState from "$lib/stores/uiState";
import { get } from "svelte/store"; 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 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 { export default class ComfyPickImageNode extends ComfyGraphNode {
static slotLayout: SlotLayout = { static slotLayout: SlotLayout = {
inputs: [ inputs: [
{ name: "images", type: "COMFYBOX_IMAGES" }, { name: "images", type: "COMFYBOX_IMAGES,COMFYBOX_IMAGE" },
{ name: "index", type: "number" },
], ],
outputs: [ outputs: [
{ name: "image", type: "COMFYBOX_IMAGE" }, { 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() { override onExecute() {
const data = this.getInputData(0) const data = this.getInputData(0)
if (data == null || !isComfyBoxImageMetadataArray(data)) { const index = this.getInputData(1) || 0
this.setOutputData(0, null) this.setValue(data, index);
return;
}
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({ LiteGraph.registerNodeType({
class: ComfyPickImageNode, class: ComfyPickImageNode,
title: "Comfy.PickImage", 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" 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 ComfyGraphNode, { type ComfyGraphNodeProperties, type DefaultWidgetLayout } from "./ComfyGraphNode";
import { clamp } from "$lib/utils"; import { clamp } from "$lib/utils";
import ComboWidget from "$lib/widgets/ComboWidget.svelte"; import ComboWidget from "$lib/widgets/ComboWidget.svelte";
import { ComfyComboNode } from "./ComfyWidgetNodes"; import { ComfyComboNode } from "./widgets";
export interface ComfyValueControlProperties extends ComfyGraphNodeProperties { export interface ComfyValueControlProperties extends ComfyGraphNodeProperties {
value: any, value: any,
action: "fixed" | "increment" | "decrement" | "randomize", action: "fixed" | "increment" | "decrement" | "randomize",
min: number, min: number,
max: number, max: number,
step: number step: number,
ignoreStepWhenRandom: boolean
} }
const INT_MAX = 1125899906842624; const INT_MAX = 1125899906842624;
@@ -18,10 +19,11 @@ export default class ComfyValueControl extends ComfyGraphNode {
override properties: ComfyValueControlProperties = { override properties: ComfyValueControlProperties = {
tags: [], tags: [],
value: null, value: null,
action: "fixed", action: "randomize",
min: -INT_MAX, min: -INT_MAX,
max: INT_MAX, max: INT_MAX,
step: 1 step: 1,
ignoreStepWhenRandom: false
} }
static slotLayout: SlotLayout = { static slotLayout: SlotLayout = {
@@ -61,15 +63,11 @@ export default class ComfyValueControl extends ComfyGraphNode {
} }
override onExecute() { 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) { if (this._aboutToChange > 0) {
this._aboutToChange -= 1; this._aboutToChange -= 1;
if (this._aboutToChange <= 0) { if (this._aboutToChange <= 0) {
const value = this._aboutToChangeValue; const value = this._aboutToChangeValue;
console.warn("ABOUTTOCHANGE", value)
this._aboutToChange = 0; this._aboutToChange = 0;
this._aboutToChangeValue = null; this._aboutToChangeValue = null;
this.triggerSlot(1, value) this.triggerSlot(1, value)
@@ -82,8 +80,26 @@ export default class ComfyValueControl extends ComfyGraphNode {
if (typeof v !== "number") if (typeof v !== "number")
return return
let min = this.properties.min let action_ = this.getInputData(2);
let max = this.properties.max 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 (min == null) min = -INT_MAX
if (max == null) max = INT_MAX if (max == null) max = INT_MAX
@@ -103,7 +119,8 @@ export default class ComfyValueControl extends ComfyGraphNode {
v -= this.properties.step; v -= this.properties.step;
break; break;
case "randomize": 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: default:
break; 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 { default as ComfyReroute } from "./ComfyReroute"
export { ComfyWidgetNode, ComfySliderNode, ComfyComboNode, ComfyTextNode } from "./ComfyWidgetNodes"
export { export {
ComfyQueueEvents, ComfyQueueEvents,
ComfyCopyAction, ComfyCopyAction,
@@ -17,4 +16,3 @@ export { default as ComfySelector } from "./ComfySelector"
export { default as ComfyTriggerNewEventNode } from "./ComfyTriggerNewEventNode" export { default as ComfyTriggerNewEventNode } from "./ComfyTriggerNewEventNode"
export { default as ComfyConfigureQueuePromptButton } from "./ComfyConfigureQueuePromptButton" export { default as ComfyConfigureQueuePromptButton } from "./ComfyConfigureQueuePromptButton"
export { default as ComfyPickImageNode } from "./ComfyPickImageNode" 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 { get, writable } from 'svelte/store';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
import type ComfyApp from "$lib/components/ComfyApp" 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 { SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import type { ComfyWidgetNode } from '$lib/nodes'; import type { ComfyNodeID } from '$lib/api';
import type { NodeID } from '$lib/api';
import { v4 as uuidv4 } from "uuid"; 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 = { type DragItemEntry = {
/* /*
@@ -60,17 +64,7 @@ export type LayoutState = {
* Items indexed by the litegraph node they're bound to * Items indexed by the litegraph node they're bound to
* Only contains drag items of type "widget" * Only contains drag items of type "widget"
*/ */
allItemsByNode: Record<NodeID, DragItemEntry>, allItemsByNode: Record<ComfyNodeID, DragItemEntry>,
/*
* Selected drag items.
*/
currentSelection: DragItemID[],
/*
* Selected LGraphNodes inside the litegraph canvas.
*/
currentSelectionNodes: LGraphNode[],
/* /*
* If true, a saved workflow is being deserialized, so ignore any * If true, a saved workflow is being deserialized, so ignore any
@@ -194,7 +188,7 @@ export type AttributesSpec = {
location: "widget" | "nodeProps" | "nodeVars" | "workflow" 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, editable: boolean,
@@ -259,7 +253,12 @@ export type AttributesSpec = {
* This should be used if there's a canShow dependent on this property so * 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. * 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()) 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. * Attributes that will show up in the properties panel.
* Their order in the list is the order they'll appear in the 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", location: "widget",
defaultValue: "", defaultValue: "",
editable: true, editable: true,
// onChanged: setNodeTitle
}, },
{ {
name: "hidden", name: "hidden",
@@ -341,7 +359,7 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
location: "widget", location: "widget",
editable: true, editable: true,
values: ["visible", "disabled", "hidden"], values: ["visible", "disabled", "hidden"],
defaultValue: "disabled", defaultValue: "hidden",
canShow: (di: IDragItem) => di.type === "widget" canShow: (di: IDragItem) => di.type === "widget"
}, },
@@ -362,7 +380,7 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
location: "widget", location: "widget",
editable: true, editable: true,
values: ["block", "hidden"], values: ["block", "hidden"],
defaultValue: "block", defaultValue: "hidden",
canShow: (di: IDragItem) => di.type === "container" canShow: (di: IDragItem) => di.type === "container"
}, },
@@ -493,7 +511,7 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
defaultValue: 0, defaultValue: 0,
min: -2 ^ 16, min: -2 ^ 16,
max: 2 ^ 16, max: 2 ^ 16,
validNodeTypes: ["ui/slider"], validNodeTypes: ["ui/number"],
}, },
{ {
name: "max", name: "max",
@@ -503,7 +521,7 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
defaultValue: 10, defaultValue: 10,
min: -2 ^ 16, min: -2 ^ 16,
max: 2 ^ 16, max: 2 ^ 16,
validNodeTypes: ["ui/slider"], validNodeTypes: ["ui/number"],
}, },
{ {
name: "step", name: "step",
@@ -513,7 +531,7 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
defaultValue: 1, defaultValue: 1,
min: -2 ^ 16, min: -2 ^ 16,
max: 2 ^ 16, max: 2 ^ 16,
validNodeTypes: ["ui/slider"], validNodeTypes: ["ui/number"],
}, },
// Button // Button
@@ -579,6 +597,7 @@ for (const cat of Object.values(ALL_ATTRIBUTES)) {
export { ALL_ATTRIBUTES }; export { ALL_ATTRIBUTES };
// TODO Should be nested by category for name uniqueness?
const defaultWidgetAttributes: Attributes = {} as any const defaultWidgetAttributes: Attributes = {} as any
const defaultWorkflowAttributes: LayoutAttributes = {} as any const defaultWorkflowAttributes: LayoutAttributes = {} as any
for (const cat of Object.values(ALL_ATTRIBUTES)) { for (const cat of Object.values(ALL_ATTRIBUTES)) {
@@ -660,11 +679,11 @@ type LayoutStateOps = {
updateChildren: (parent: IDragItem, children: IDragItem[]) => IDragItem[], updateChildren: (parent: IDragItem, children: IDragItem[]) => IDragItem[],
nodeAdded: (node: LGraphNode, options: LGraphAddNodeOptions) => void, nodeAdded: (node: LGraphNode, options: LGraphAddNodeOptions) => void,
nodeRemoved: (node: LGraphNode, options: LGraphRemoveNodeOptions) => 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, ungroup: (container: ContainerLayout) => void,
getCurrentSelection: () => IDragItem[], findLayoutEntryForNode: (nodeId: ComfyNodeID) => DragItemEntry | null,
findLayoutEntryForNode: (nodeId: NodeID) => DragItemEntry | null, findLayoutForNode: (nodeId: ComfyNodeID) => IDragItem | null,
findLayoutForNode: (nodeId: NodeID) => IDragItem | null,
serialize: () => SerializedLayoutState, serialize: () => SerializedLayoutState,
deserialize: (data: SerializedLayoutState, graph: LGraph) => void, deserialize: (data: SerializedLayoutState, graph: LGraph) => void,
initDefaultLayout: () => void, initDefaultLayout: () => void,
@@ -676,8 +695,6 @@ const store: Writable<LayoutState> = writable({
root: null, root: null,
allItems: {}, allItems: {},
allItemsByNode: {}, allItemsByNode: {},
currentSelection: [],
currentSelectionNodes: [],
isMenuOpen: false, isMenuOpen: false,
isConfiguring: true, isConfiguring: true,
refreshPropsPanel: writable(0), 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 { function findDefaultContainerForInsertion(): ContainerLayout | null {
const state = get(store); const state = get(store);
@@ -706,6 +737,16 @@ function findDefaultContainerForInsertion(): ContainerLayout | null {
return 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 { function addContainer(parent: ContainerLayout | null, attrs: Partial<Attributes> = {}, index?: number): ContainerLayout {
const state = get(store); const state = get(store);
const dragItem: ContainerLayout = { const dragItem: ContainerLayout = {
@@ -718,19 +759,26 @@ function addContainer(parent: ContainerLayout | null, attrs: Partial<Attributes>
...attrs ...attrs
} }
} }
const entry: DragItemEntry = { dragItem, children: [], parent: null }; 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; state.allItems[dragItem.id] = entry;
if (parent) { if (parent) {
moveItem(dragItem, parent, index) moveItem(dragItem, parent, index)
} }
console.debug("[layoutState] addContainer", state) console.debug("[layoutState] addContainer", state)
store.set(state) store.set(state)
// runOnChangedForWidgetDefaults(dragItem)
return dragItem; return dragItem;
} }
function addWidget(parent: ContainerLayout, node: ComfyWidgetNode, attrs: Partial<Attributes> = {}, index?: number): WidgetLayout { function addWidget(parent: ContainerLayout, node: ComfyWidgetNode, attrs: Partial<Attributes> = {}, index?: number): WidgetLayout {
const state = get(store); const state = get(store);
const widgetName = "Widget" const widgetName = node.title || "Widget"
const dragItem: WidgetLayout = { const dragItem: WidgetLayout = {
type: "widget", type: "widget",
id: uuidv4(), id: uuidv4(),
@@ -739,16 +787,23 @@ function addWidget(parent: ContainerLayout, node: ComfyWidgetNode, attrs: Partia
attrs: { attrs: {
...defaultWidgetAttributes, ...defaultWidgetAttributes,
title: widgetName, title: widgetName,
nodeDisabledState: "disabled",
...attrs ...attrs
} }
} }
const parentEntry = state.allItems[parent.id]
const entry: DragItemEntry = { dragItem, children: [], parent: null }; 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; 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; state.allItemsByNode[node.id] = entry;
console.debug("[layoutState] addWidget", state) console.debug("[layoutState] addWidget", state)
moveItem(dragItem, parent, index) moveItem(dragItem, parent, index)
// runOnChangedForWidgetDefaults(dragItem)
return dragItem; return dragItem;
} }
@@ -784,24 +839,62 @@ function removeEntry(state: LayoutState, id: DragItemID) {
} }
function nodeAdded(node: LGraphNode, options: LGraphAddNodeOptions) { function nodeAdded(node: LGraphNode, options: LGraphAddNodeOptions) {
// Only concern ourselves with widget nodes
if (!isComfyWidgetNode(node))
return;
const state = get(store) const state = get(store)
if (state.isConfiguring) if (state.isConfiguring)
return; return;
let attrs: Partial<Attributes> = {}
if (options.addedBy === "moveIntoSubgraph" || options.addedBy === "moveOutOfSubgraph") { if (options.addedBy === "moveIntoSubgraph" || options.addedBy === "moveOutOfSubgraph") {
// All we need to do is update the nodeID linked to this node. // All we need to do is update the nodeID linked to this node.
const item = state.allItemsByNode[options.prevNodeId] const item = state.allItemsByNode[options.prevNodeID]
delete state.allItemsByNode[options.prevNodeId] delete state.allItemsByNode[options.prevNodeID]
state.allItemsByNode[node.id] = item state.allItemsByNode[node.id] = item
return; 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(); const parent = findDefaultContainerForInsertion();
console.debug("[layoutState] nodeAdded", node) console.debug("[layoutState] nodeAdded", node.id)
if ("svelteComponentType" in node) { addWidget(parent, node, attrs);
addWidget(parent, node as ComfyWidgetNode);
}
} }
function nodeRemoved(node: LGraphNode, options: LGraphRemoveNodeOptions) { function nodeRemoved(node: LGraphNode, options: LGraphRemoveNodeOptions) {
@@ -830,7 +923,7 @@ function nodeRemoved(node: LGraphNode, options: LGraphRemoveNodeOptions) {
function moveItem(target: IDragItem, to: ContainerLayout, index?: number) { function moveItem(target: IDragItem, to: ContainerLayout, index?: number) {
const state = get(store) const state = get(store)
const entry = state.allItems[target.id] 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; return;
if (entry.parent) { if (entry.parent) {
@@ -858,33 +951,27 @@ function moveItem(target: IDragItem, to: ContainerLayout, index?: number) {
store.set(state) store.set(state)
} }
function getCurrentSelection(): IDragItem[] { function groupItems(dragItemIDs: DragItemID[], attrs: Partial<Attributes> = {}): ContainerLayout {
const state = get(store) if (dragItemIDs.length === 0)
return state.currentSelection.map(id => state.allItems[id].dragItem)
}
function groupItems(dragItems: IDragItem[], attrs: Partial<Attributes> = {}): ContainerLayout {
if (dragItems.length === 0)
return; return;
const state = get(store) 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") if (parent === null || parent.type !== "container")
return; return;
let index = undefined; let index = undefined;
if (parent) { 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) if (indexFound !== -1)
index = indexFound 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 itemID of dragItemIDs) {
const item = state.allItems[itemID].dragItem;
for (const item of dragItems) {
moveItem(item, container) moveItem(item, container)
} }
@@ -924,7 +1011,7 @@ function ungroup(container: ContainerLayout) {
store.set(state) store.set(state)
} }
function findLayoutEntryForNode(nodeId: NodeID): DragItemEntry | null { function findLayoutEntryForNode(nodeId: ComfyNodeID): DragItemEntry | null {
const state = get(store) const state = get(store)
const found = Object.entries(state.allItems).find(pair => const found = Object.entries(state.allItems).find(pair =>
pair[1].dragItem.type === "widget" pair[1].dragItem.type === "widget"
@@ -934,7 +1021,7 @@ function findLayoutEntryForNode(nodeId: NodeID): DragItemEntry | null {
return null; return null;
} }
function findLayoutForNode(nodeId: NodeID): WidgetLayout | null { function findLayoutForNode(nodeId: ComfyNodeID): WidgetLayout | null {
const found = findLayoutEntryForNode(nodeId); const found = findLayoutEntryForNode(nodeId);
if (!found) if (!found)
return null; return null;
@@ -946,8 +1033,6 @@ function initDefaultLayout() {
root: null, root: null,
allItems: {}, allItems: {},
allItemsByNode: {}, allItemsByNode: {},
currentSelection: [],
currentSelectionNodes: [],
isMenuOpen: false, isMenuOpen: false,
isConfiguring: false, isConfiguring: false,
refreshPropsPanel: writable(0), refreshPropsPanel: writable(0),
@@ -1034,7 +1119,9 @@ function deserialize(data: SerializedLayoutState, graph: LGraph) {
if (dragItem.type === "widget") { if (dragItem.type === "widget") {
const widget = dragItem as WidgetLayout; 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 allItemsByNode[entry.dragItem.nodeId] = dragEntry
} }
} }
@@ -1059,8 +1146,6 @@ function deserialize(data: SerializedLayoutState, graph: LGraph) {
root, root,
allItems, allItems,
allItemsByNode, allItemsByNode,
currentSelection: [],
currentSelectionNodes: [],
isMenuOpen: false, isMenuOpen: false,
isConfiguring: false, isConfiguring: false,
refreshPropsPanel: writable(0), refreshPropsPanel: writable(0),
@@ -1091,7 +1176,7 @@ const layoutStateStore: WritableLayoutStateStore =
updateChildren, updateChildren,
nodeAdded, nodeAdded,
nodeRemoved, nodeRemoved,
getCurrentSelection, moveItem,
groupItems, groupItems,
findLayoutEntryForNode, findLayoutEntryForNode,
findLayoutForNode, 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 { Progress, SerializedPromptInputsAll, SerializedPromptOutputs } from "$lib/components/ComfyApp";
import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes"; import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes";
import notify from "$lib/notify"; import notify from "$lib/notify";
@@ -14,12 +14,13 @@ type QueueStateOps = {
queueUpdated: (resp: ComfyAPIQueueResponse) => void, queueUpdated: (resp: ComfyAPIQueueResponse) => void,
historyUpdated: (resp: ComfyAPIHistoryResponse) => void, historyUpdated: (resp: ComfyAPIHistoryResponse) => void,
statusUpdated: (status: ComfyAPIStatusResponse | null) => void, statusUpdated: (status: ComfyAPIStatusResponse | null) => void,
executingUpdated: (promptID: PromptID | null, runningNodeID: NodeID | null) => void, executionStart: (promptID: PromptID) => void,
executionCached: (promptID: PromptID, nodes: NodeID[]) => void, executingUpdated: (promptID: PromptID | null, runningNodeID: ComfyNodeID | null) => void,
executionCached: (promptID: PromptID, nodes: ComfyNodeID[]) => void,
executionError: (promptID: PromptID, message: string) => void, executionError: (promptID: PromptID, message: string) => void,
progressUpdated: (progress: Progress) => void progressUpdated: (progress: Progress) => void
afterQueued: (promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) => 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 = { export type QueueEntry = {
@@ -30,7 +31,7 @@ export type QueueEntry = {
promptID: PromptID, promptID: PromptID,
prompt: SerializedPromptInputsAll, prompt: SerializedPromptInputsAll,
extraData: ComfyBoxPromptExtraData, extraData: ComfyBoxPromptExtraData,
goodOutputs: NodeID[], goodOutputs: ComfyNodeID[],
/* Data not sent by ComfyUI's API, lost on page refresh */ /* Data not sent by ComfyUI's API, lost on page refresh */
@@ -38,8 +39,8 @@ export type QueueEntry = {
outputs: SerializedPromptOutputs, outputs: SerializedPromptOutputs,
/* Nodes in of the workflow that have finished running so far. */ /* Nodes in of the workflow that have finished running so far. */
nodesRan: Set<NodeID>, nodesRan: Set<ComfyNodeID>,
cachedNodes: Set<NodeID> cachedNodes: Set<ComfyNodeID>
} }
export type CompletedQueueEntry = { export type CompletedQueueEntry = {
@@ -54,7 +55,7 @@ export type QueueState = {
queuePending: Writable<QueueEntry[]>, queuePending: Writable<QueueEntry[]>,
queueCompleted: Writable<CompletedQueueEntry[]>, queueCompleted: Writable<CompletedQueueEntry[]>,
queueRemaining: number | "X" | null; queueRemaining: number | "X" | null;
runningNodeID: NodeID | null; runningNodeID: ComfyNodeID | null;
progress: Progress | null, progress: Progress | null,
isInterrupting: boolean isInterrupting: boolean
} }
@@ -161,7 +162,7 @@ function moveToCompleted(index: number, queue: Writable<QueueEntry[]>, status: Q
store.set(state) store.set(state)
} }
function executingUpdated(promptID: PromptID, runningNodeID: NodeID | null) { function executingUpdated(promptID: PromptID, runningNodeID: ComfyNodeID | null) {
console.debug("[queueState] executingUpdated", promptID, runningNodeID) console.debug("[queueState] executingUpdated", promptID, runningNodeID)
store.update((s) => { store.update((s) => {
s.progress = null; 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) console.debug("[queueState] executionCached", promptID, nodes)
store.update(s => { store.update(s => {
const [index, entry, queue] = findEntryInPending(promptID); const [index, entry, queue] = findEntryInPending(promptID);
@@ -235,10 +236,8 @@ function executionError(promptID: PromptID, message: string) {
}) })
} }
function afterQueued(promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) { function createNewQueueEntry(promptID: PromptID, number: number = -1, prompt: SerializedPromptInputsAll = {}, extraData: any = {}): QueueEntry {
console.debug("[queueState] afterQueued", promptID, Object.keys(prompt)) return {
store.update(s => {
const entry: QueueEntry = {
number, number,
queuedAt: new Date(), // Now queuedAt: new Date(), // Now
finishedAt: null, finishedAt: null,
@@ -250,14 +249,44 @@ function afterQueued(promptID: PromptID, number: number, prompt: SerializedPromp
nodesRan: new Set(), nodesRan: new Set(),
cachedNodes: new Set() cachedNodes: new Set()
} }
}
function executionStart(promptID: PromptID) {
console.debug("[queueState] executionStart", promptID)
store.update(s => {
const [index, entry, queue] = findEntryInPending(promptID);
if (entry == null) {
const entry = createNewQueueEntry(promptID);
s.queuePending.update(qp => { qp.push(entry); return qp }) s.queuePending.update(qp => { qp.push(entry); return qp })
console.debug("[queueState] ADD PROMPT", promptID) console.debug("[queueState] ADD PROMPT", promptID)
}
s.isInterrupting = false; s.isInterrupting = false;
return s 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) console.debug("[queueState] onExecuted", promptID, nodeID, output)
store.update(s => { store.update(s => {
const [index, entry, queue] = findEntryInPending(promptID) const [index, entry, queue] = findEntryInPending(promptID)
@@ -279,6 +308,7 @@ const queueStateStore: WritableQueueStateStore =
historyUpdated, historyUpdated,
statusUpdated, statusUpdated,
progressUpdated, progressUpdated,
executionStart,
executingUpdated, executingUpdated,
executionCached, executionCached,
executionError, 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 layoutState, { type WidgetLayout } from "$lib/stores/layoutState";
import ComboWidget from "$lib/widgets/ComboWidget.svelte"; import selectionState from "$lib/stores/selectionState";
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 type { FileData as GradioFileData } from "@gradio/upload"; 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 { export function clamp(n: number, min: number, max: number): number {
return Math.min(Math.max(n, min), max) 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); 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") { export function download(filename: string, text: string, type: string = "text/plain") {
const blob = new Blob([text], { type: type }); const blob = new Blob([text], { type: type });
const url = URL.createObjectURL(blob); 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) { export function startDrag(evt: MouseEvent) {
const dragItemId: string = evt.target.dataset["dragItemId"]; const dragItemId: string = evt.target.dataset["dragItemId"];
const ss = get(selectionState)
const ls = get(layoutState) const ls = get(layoutState)
if (evt.button !== 0) { if (evt.button !== 0) {
if (ls.currentSelection.length <= 1 && !ls.isMenuOpen) if (ss.currentSelection.length <= 1 && !ls.isMenuOpen)
ls.currentSelection = [dragItemId] ss.currentSelection = [dragItemId]
return; return;
} }
@@ -50,24 +55,110 @@ export function startDrag(evt: MouseEvent) {
console.debug("startDrag", item) console.debug("startDrag", item)
if (evt.ctrlKey) { if (evt.ctrlKey) {
const index = ls.currentSelection.indexOf(item.id) const index = ss.currentSelection.indexOf(item.id)
if (index === -1) if (index === -1)
ls.currentSelection.push(item.id); ss.currentSelection.push(item.id);
else else
ls.currentSelection.splice(index, 1); ss.currentSelection.splice(index, 1);
ls.currentSelection = ls.currentSelection; ss.currentSelection = ss.currentSelection;
} }
else { 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) layoutState.set(ls)
selectionState.set(ss)
}; };
export function stopDrag(evt: MouseEvent) { 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 { export function workflowToGraphVis(workflow: SerializedLGraph): string {
let out = "digraph {\n" let out = "digraph {\n"
@@ -87,32 +178,38 @@ export function promptToGraphVis(prompt: SerializedPrompt): string {
for (const pair of Object.entries(prompt.output)) { for (const pair of Object.entries(prompt.output)) {
const [id, o] = pair; const [id, o] = pair;
const outNode = prompt.workflow.nodes.find(n => n.id == id) const outNode = prompt.workflow.nodes.find(n => n.id == id)
if (outNode) {
for (const pair2 of Object.entries(o.inputs)) { for (const pair2 of Object.entries(o.inputs)) {
const [inpName, i] = pair2; const [inpName, i] = pair2;
if (Array.isArray(i) && i.length === 2 && typeof i[0] === "string" && typeof i[1] === "number") { if (Array.isArray(i) && i.length === 2 && typeof i[0] === "string" && typeof i[1] === "number") {
// Link // Link
const inpNode = prompt.workflow.nodes.find(n => n.id == i[0]) const inpNode = prompt.workflow.nodes.find(n => n.id == i[0])
if (inpNode) {
out += `"${inpNode.title}" -> "${outNode.title}"\n` out += `"${inpNode.title}" -> "${outNode.title}"\n`
} }
}
else { else {
// Value // Value
out += `"${id}-${inpName}-${i}" -> "${outNode.title}"\n` out += `"${id}-${inpName}-${i}" -> "${outNode.title}"\n`
} }
} }
} }
}
out += "}" out += "}"
return out return out
} }
export function getNodeInfo(nodeId: NodeID): string { export function getNodeInfo(nodeId: ComfyNodeID): string {
let app = (window as any).app; let app = (window as any).app;
if (!app || !app.lGraph) if (!app || !app.lGraph)
return String(nodeId); return String(nodeId);
const title = app.lGraph.getNodeById(nodeId)?.title || String(nodeId); const displayNodeID = nodeId ? (nodeId.split("-")[0]) : String(nodeId);
return title + " (" + nodeId + ")"
const title = app.lGraph.getNodeByIdRecursive(nodeId)?.title || String(nodeId);
return title + " (" + displayNodeID + ")"
} }
export const debounce = (callback: Function, wait = 250) => { 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 * Convenient type for passing around image filepaths and their metadata with
* wires. Needs to be converted to a filename for use with LoadImage. * 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) 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 { export function parseWhateverIntoImageMetadata(param: any): ComfyBoxImageMetadata[] | null {
let meta: ComfyBoxImageMetadata[] | null = null let meta: ComfyBoxImageMetadata[] | null = null
@@ -314,12 +435,26 @@ export function parseWhateverIntoImageMetadata(param: any): ComfyBoxImageMetadat
meta = param meta = param
} }
else if (isComfyExecutionResult(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; 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 { export function comfyBoxImageToComfyFile(image: ComfyBoxImageMetadata): ComfyImageLocation {
return image.comfyUIFile return image.comfyUIFile
} }

View File

@@ -1,9 +1,7 @@
import type { IWidget, LGraphNode } from "@litegraph-js/core"; import { LGraphNode, LiteGraph } from "@litegraph-ts/core";
import ComfyValueControlWidget from "./widgets/ComfyValueControlWidget";
import type { ComfyInputConfig } from "./IComfyInputSlot";
import type IComfyInputSlot from "./IComfyInputSlot"; import type IComfyInputSlot from "./IComfyInputSlot";
import { BuiltInSlotShape, LiteGraph } from "@litegraph-ts/core"; import type { ComfyInputConfig } from "./IComfyInputSlot";
import { ComfyComboNode, ComfySliderNode, ComfyTextNode } from "./nodes"; import { ComfyComboNode, ComfyNumberNode, ComfyTextNode } from "./nodes/widgets";
type WidgetFactory = (node: LGraphNode, inputName: string, inputData: any) => IComfyInputSlot; 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 FLOAT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {
const config = getNumberDefaults(inputData, 0.5); 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 INT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {
const config = getNumberDefaults(inputData, 1); 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 => { const STRING: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
import Select from 'svelte-select'; import Select from 'svelte-select';
// import VirtualList from '$lib/components/VirtualList.svelte'; // import VirtualList from '$lib/components/VirtualList.svelte';
import VirtualList from 'svelte-tiny-virtual-list'; 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 { type WidgetLayout } from "$lib/stores/layoutState";
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from "svelte/store";
import { isDisabled } from "./utils" import { isDisabled } from "./utils"
@@ -13,6 +13,7 @@
let node: ComfyComboNode | null = null; let node: ComfyComboNode | null = null;
let nodeValue: Writable<string> | null = null; let nodeValue: Writable<string> | null = null;
let propsChanged: Writable<number> | null = null; let propsChanged: Writable<number> | null = null;
let lightUp: Writable<boolean> = writable(false);
let valuesForCombo: Writable<any[]> | null = null; let valuesForCombo: Writable<any[]> | null = null;
let lastConfigured: any = null; let lastConfigured: any = null;
let option: any = null; let option: any = null;
@@ -40,6 +41,7 @@
node = widget.node as ComfyComboNode node = widget.node as ComfyComboNode
nodeValue = node.value; nodeValue = node.value;
propsChanged = node.propsChanged; propsChanged = node.propsChanged;
lightUp = node.lightUp;
valuesForCombo = node.valuesForCombo; valuesForCombo = node.valuesForCombo;
lastConfigured = $valuesForCombo lastConfigured = $valuesForCombo
} }
@@ -52,16 +54,8 @@
activeIndex = values.findIndex(v => v.value === value); activeIndex = values.findIndex(v => v.value === value);
} }
$: $valuesForCombo != lastConfigured && flashOnRefreshed(); $: if ($lightUp)
let lightUp = false; setTimeout(() => ($lightUp = false), 1000);
function flashOnRefreshed() {
lastConfigured = $valuesForCombo
if (lastConfigured != null) {
lightUp = true;
setTimeout(() => (lightUp = false), 1000);
}
}
function getLinkValue() { function getLinkValue() {
if (!node) if (!node)
@@ -137,7 +131,7 @@
</script> </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} {#key $valuesForCombo}
{#if node !== null && nodeValue !== null} {#if node !== null && nodeValue !== null}
{#if $valuesForCombo == null} {#if $valuesForCombo == null}
@@ -172,10 +166,11 @@
<div class="comfy-select-list" slot="list" let:filteredItems> <div class="comfy-select-list" slot="list" let:filteredItems>
{#if filteredItems.length > 0} {#if filteredItems.length > 0}
{@const itemSize = isMobile ? 50 : 25} {@const itemSize = isMobile ? 50 : 25}
{@const itemsToShow = isMobile ? 10 : 30}
<VirtualList <VirtualList
items={filteredItems} items={filteredItems}
width="100%" width="100%"
height={Math.min(filteredItems.length, 10) * itemSize} height={Math.min(filteredItems.length, itemsToShow) * itemSize}
itemCount={filteredItems.length} itemCount={filteredItems.length}
{itemSize} {itemSize}
overscanCount={5} overscanCount={5}
@@ -252,7 +247,7 @@
--chevron-color: var(--body-text-color); --chevron-color: var(--body-text-color);
--border: 1px solid var(--input-border-color); --border: 1px solid var(--input-border-color);
--border-hover: 1px solid var(--input-border-color-hover); --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-focused: 0px;
--border-radius: 0px; --border-radius: 0px;
--list-background: var(--comfy-dropdown-list-background); --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 { StaticImage } from "$lib/components/gradio/image";
import type { Styles } from "@gradio/utils"; import type { Styles } from "@gradio/utils";
import type { WidgetLayout } from "$lib/stores/layoutState"; import type { WidgetLayout } from "$lib/stores/layoutState";
import type { Writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
import type { ComfyGalleryNode } from "$lib/nodes/ComfyWidgetNodes";
import type { FileData as GradioFileData } from "@gradio/upload"; import type { FileData as GradioFileData } from "@gradio/upload";
import type { SelectData as GradioSelectData } from "@gradio/utils"; import type { SelectData as GradioSelectData } from "@gradio/utils";
import { clamp, comfyBoxImageToComfyURL, type ComfyBoxImageMetadata } from "$lib/utils"; import { clamp, comfyBoxImageToComfyURL, type ComfyBoxImageMetadata } from "$lib/utils";
import { f7 } from "framework7-svelte"; import { f7 } from "framework7-svelte";
import type { ComfyGalleryNode } from "$lib/nodes/widgets";
export let widget: WidgetLayout | null = null; export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false; export let isMobile: boolean = false;
@@ -19,8 +19,8 @@
let nodeValue: Writable<ComfyBoxImageMetadata[]> | null = null; let nodeValue: Writable<ComfyBoxImageMetadata[]> | null = null;
let propsChanged: Writable<number> | null = null; let propsChanged: Writable<number> | null = null;
let option: number | null = null; let option: number | null = null;
let imageWidth: number = 1; let imageWidth: Writable<number> = writable(0);
let imageHeight: number = 1; let imageHeight: Writable<number> = writable(0);
let selected_image: number | null = null; let selected_image: number | null = null;
$: widget && setNodeValue(widget); $: widget && setNodeValue(widget);
@@ -30,6 +30,8 @@
node = widget.node as ComfyGalleryNode node = widget.node as ComfyGalleryNode
nodeValue = node.value; nodeValue = node.value;
propsChanged = node.propsChanged; propsChanged = node.propsChanged;
imageWidth = node.imageWidth
imageHeight = node.imageHeight
if ($nodeValue != null) { if ($nodeValue != null) {
if (node.properties.index < 0 || node.properties.index >= $nodeValue.length) { if (node.properties.index < 0 || node.properties.index >= $nodeValue.length) {
@@ -45,18 +47,9 @@
} }
let element: HTMLDivElement; let element: HTMLDivElement;
$: if (node) {
if (imageWidth > 1 || imageHeight > 1) {
node.imageSize = [imageWidth, imageHeight]
}
else {
node.imageSize = [1, 1]
}
}
let mobileLightbox = null; let mobileLightbox = null;
function showMobileLightbox(event: Event) { function showMobileLightbox(source: HTMLImageElement) {
if (!f7) if (!f7)
return return
@@ -65,16 +58,14 @@
mobileLightbox = null; mobileLightbox = null;
} }
const source = (event.target || event.srcElement) as HTMLImageElement;
const galleryElem = source.closest<HTMLDivElement>("div.block") 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) { if (!galleryElem || ImageViewer.all_gallery_buttons(galleryElem).length === 0) {
console.error("No buttons found on gallery element!", galleryElem) console.error("No buttons found on gallery element!", galleryElem)
return; return;
} }
const allGalleryButtons = ImageViewer.all_gallery_buttons(galleryElem); const allGalleryButtons = ImageViewer.all_gallery_buttons(galleryElem);
const selectedSource = source.src
const images = allGalleryButtons.map(button => { const images = allGalleryButtons.map(button => {
return { return {
@@ -91,56 +82,29 @@
type: 'popup', type: 'popup',
}); });
mobileLightbox.open(selected_image) mobileLightbox.open(selected_image)
event.stopPropagation()
} }
function setupImageForMobileLightbox(e: HTMLImageElement) { function onClicked(e: CustomEvent<HTMLImageElement>) {
if (e.dataset.modded === "true") if (isMobile) {
return; showMobileLightbox(e.detail)
}
e.dataset.modded = "true"; else {
e.style.cursor = "pointer"; ImageViewer.instance.showLightbox(e.detail)
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 onSelect(e: CustomEvent<GradioSelectData>) { 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 // Update index
node.setProperty("index", e.detail.index as number) node.setProperty("index", e.detail.index as number)
} }
$: if ($propsChanged > -1 && widget && $nodeValue) { $: if ($propsChanged > -1 && widget && $nodeValue) {
if (widget.attrs.variant === "image") { if (widget.attrs.variant === "image") {
selected_image = $nodeValue.length - 1
node.setProperty("index", selected_image) node.setProperty("index", selected_image)
} }
else {
node.setProperty("index", $nodeValue.length > 0 ? 0 : null)
}
} }
else { else {
node.setProperty("index", null) node.setProperty("index", null)
@@ -160,8 +124,8 @@
value={url} value={url}
show_label={widget.attrs.title != ""} show_label={widget.attrs.title != ""}
label={widget.attrs.title} label={widget.attrs.title}
bind:imageWidth bind:imageWidth={$imageWidth}
bind:imageHeight bind:imageHeight={$imageHeight}
/> />
{:else} {:else}
<Empty size="large" unpadded_box={true}><Image /></Empty> <Empty size="large" unpadded_box={true}><Image /></Empty>
@@ -181,8 +145,9 @@
root={""} root={""}
root_url={""} root_url={""}
on:select={onSelect} on:select={onSelect}
bind:imageWidth on:clicked={onClicked}
bind:imageHeight bind:imageWidth={$imageWidth}
bind:imageHeight={$imageHeight}
bind:selected_image bind:selected_image
/> />
</div> </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,5 +1,5 @@
<script lang="ts"> <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 { type WidgetLayout } from "$lib/stores/layoutState";
import { Range } from "$lib/components/gradio/form"; import { Range } from "$lib/components/gradio/form";
import { get, type Writable } from "svelte/store"; import { get, type Writable } from "svelte/store";
@@ -8,7 +8,7 @@
import { isDisabled } from "./utils" import { isDisabled } from "./utils"
export let widget: WidgetLayout | null = null; export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false; export let isMobile: boolean = false;
let node: ComfySliderNode | null = null; let node: ComfyNumberNode | null = null;
let nodeValue: Writable<number> | null = null; let nodeValue: Writable<number> | null = null;
let propsChanged: Writable<number> | null = null; let propsChanged: Writable<number> | null = null;
let option: number | null = null; let option: number | null = null;
@@ -18,7 +18,7 @@
function setNodeValue(widget: WidgetLayout) { function setNodeValue(widget: WidgetLayout) {
if (widget) { if (widget) {
node = widget.node as ComfySliderNode node = widget.node as ComfyNumberNode
nodeValue = node.value; nodeValue = node.value;
propsChanged = node.propsChanged; propsChanged = node.propsChanged;
setOption($nodeValue); // don't react on option setOption($nodeValue); // don't react on option

View File

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

View File

@@ -1,21 +1,21 @@
<script lang="ts"> <script lang="ts">
import { TextBox } from "@gradio/form"; import { TextBox } from "@gradio/form";
import type { ComfyComboNode } from "$lib/nodes/index";
import { type WidgetLayout } from "$lib/stores/layoutState"; 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 { isDisabled } from "./utils"
import type { ComfyTextNode } from "$lib/nodes/widgets";
export let widget: WidgetLayout | null = null; export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false; export let isMobile: boolean = false;
let node: ComfyComboNode | null = null;
let node: ComfyTextNode | null = null;
let nodeValue: Writable<string> | null = null; let nodeValue: Writable<string> | null = null;
let propsChanged: Writable<number> | null = null; let propsChanged: Writable<number> | null = null;
let itemValue: WidgetUIStateStore | null = null;
$: widget && setNodeValue(widget); $: widget && setNodeValue(widget);
function setNodeValue(widget: WidgetLayout) { function setNodeValue(widget: WidgetLayout) {
if (widget) { if (widget) {
node = widget.node as ComfySliderNode node = widget.node as ComfyTextNode
nodeValue = node.value; nodeValue = node.value;
propsChanged = node.propsChanged; 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 type { IDragItem } from "$lib/stores/layoutState";
import layoutState 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"; 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) { export function isDisabled(widget: IDragItem) {
if (widget.attrs.disabled) if (widget.attrs.disabled)
return true; return true;
if (widget.type === "widget") { if (widget.type === "widget") {
return widget.attrs.nodeDisabledState === "disabled" && widget.node.mode === NodeMode.NEVER return widget.attrs.nodeDisabledState === "disabled" && isNodeDisabled(widget.node)
} }
return false; return false;
@@ -19,7 +32,7 @@ export function isHidden(widget: IDragItem) {
return true; return true;
if (widget.type === "widget") { if (widget.type === "widget") {
return widget.attrs.nodeDisabledState === "hidden" && widget.node.mode === NodeMode.NEVER return widget.attrs.nodeDisabledState === "hidden" && isNodeDisabled(widget.node)
} }
return false; 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'; import App from './App.svelte';
LiteGraph.use_uuids = true; configureLitegraph()
const comfyApp = new ComfyApp();
(window as any).app = comfyApp;
const app = new App({ const app = new App({
target: document.getElementById('app'), target: document.getElementById("app-root"),
props: { app: comfyApp }
}) })
export default app; export default app;

View File

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

View File

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